11 KiB
Telegram Mini App Knowledge Base
Everything we've learned building Telegram Mini Apps. Read this before starting a new project or debugging issues.
🔴 Critical Issues (Will Bite You)
1. Safe Area / Fullscreen Mode
The Problem: In fullscreen mode, Telegram overlays its controls (X Close, chevron) on top of your app. Content gets hidden behind them.
Why It's Tricky:
safeAreaInsetandcontentSafeAreaInsetcan return 0 initially- Values differ based on how app is opened (folder vs direct chat)
- Values are async — can change after initial render
- iOS and Android have different safe areas
The Solution:
// Use reactive hook, not one-time check
function useSafeAreaInset() {
const [insets, setInsets] = useState({ top: 0, bottom: 0 });
useEffect(() => {
const webApp = window.Telegram?.WebApp;
if (!webApp) return;
const update = () => {
const safeArea = webApp.safeAreaInset || { top: 0 };
const contentSafeArea = webApp.contentSafeAreaInset || { top: 0 };
const isFullscreen = webApp.isFullscreen;
let top = Math.max(safeArea.top || 0, contentSafeArea.top || 0);
// Minimum fallbacks when in fullscreen but values are low
if (isFullscreen && top < 80) {
top = webApp.platform === 'ios' ? 100 : 80;
}
setInsets({ top, bottom: safeArea.bottom || 0 });
};
update();
// Listen for changes
webApp.onEvent?.('safeAreaChanged', update);
webApp.onEvent?.('fullscreenChanged', update);
// Fallback: poll every 500ms
const interval = setInterval(update, 500);
return () => clearInterval(interval);
}, []);
return insets;
}
Sticky Headers:
// WRONG - content shows through gap
<div className="sticky top-0">Header</div>
// CORRECT - background covers full safe area
<div
className="sticky top-0 bg-[var(--tg-theme-bg-color)]"
style={{ paddingTop: safeAreaInset.top }}
>
Header
</div>
2. position: fixed Doesn't Work
The Problem: Telegram Mini Apps apply CSS transform to the container, which breaks position: fixed elements.
Symptoms: Bottom sheets, modals, tooltips render in wrong position.
The Solution: Use React createPortal to render to document.body:
import { createPortal } from 'react-dom';
function Modal({ children }) {
return createPortal(
<div className="fixed inset-0 z-[9999]">{children}</div>,
document.body
);
}
3. React Renders "0" as Text
The Problem: {value && value > 0 && <Component />} renders literal "0" when value is 0.
The Solution:
// WRONG
{count && count > 0 && <span>{count}</span>}
// CORRECT
{count != null && count > 0 && <span>{count}</span>}
// OR
{count > 0 && <span>{count}</span>}
// OR
{!!count && <span>{count}</span>}
4. BackButton Click Handler Doesn't Fire
The Problem: Telegram BackButton appears but clicking does nothing.
Why: Using raw window.Telegram.WebApp.BackButton with stale closures.
The Solution: Use @telegram-apps/sdk:
import {
mountBackButton,
showBackButton,
hideBackButton,
onBackButtonClick,
offBackButtonClick
} from '@telegram-apps/sdk';
// Mount once at app init
mountBackButton();
// In component
useEffect(() => {
if (shouldShowBack) {
showBackButton();
onBackButtonClick(handleBack);
} else {
hideBackButton();
}
return () => offBackButtonClick(handleBack);
}, [shouldShowBack, handleBack]);
5. Sharing with Inline Mode
The Problem: WebApp.shareMessage() fails with cryptic errors.
Requirements:
- Bot must have inline mode enabled via @BotFather (
/setinline) - Must call
savePreparedInlineMessageon backend first - Pass the
prepared_message_idtoshareMessage()
Basic Flow:
Frontend → POST /api/prepare-share → Backend calls savePreparedInlineMessage
← Returns prepared_message_id
Frontend → WebApp.shareMessage(id) → Native share picker
Key Insight: prepared_message_id is Single-Use
Once a prepared_message_id is consumed by shareMessage() (whether sent or dismissed), it cannot be reused. This affects UX:
- User taps share → prepares message → opens picker → dismisses → taps share again → fails
- Need to prepare a fresh message for each share attempt
Two Approaches:
A) Dynamic Preparation (per-tap) Prepare a fresh message every time user taps share:
const handleShare = async () => {
const { prepared_message_id } = await fetch('/api/prepare-share').then(r => r.json());
await WebApp.shareMessage(prepared_message_id);
};
- ✅ Always works, each tap gets fresh ID
- ⚠️ More API calls
- ⚠️ Slight delay before picker opens
B) Static Content Caching (recommended for frequent shares)
Use static inline results with allow_user_chats: true. The content stays the same, so you can cache results:
// Backend: savePreparedInlineMessage with static result
const result = {
type: 'photo',
photo_url: 'https://cdn.example.com/static-card.jpg', // Same URL always
thumbnail_url: ...,
};
const { prepared_message_id } = await bot.savePreparedInlineMessage(userId, result, {
allow_user_chats: true,
allow_bot_chats: true,
allow_group_chats: true,
allow_channel_chats: true,
});
// Frontend: prepare once, reuse pattern
const handleShare = async () => {
// For static content, prepare fresh each time anyway (IDs are single-use)
const { prepared_message_id } = await fetch('/api/prepare-share-static').then(r => r.json());
await WebApp.shareMessage(prepared_message_id);
};
- ✅ Works reliably for multi-share scenarios
- ✅ Image can be cached on CDN
- ⚠️ Content is same for all shares of that item
Fallback chain:
- Try native
shareMessage(requires inline mode) - Try sending image to bot chat (user forwards manually)
- Fall back to text share via
web_app_open_tg_link
Known Behaviors (2026-02):
shareMessagerequires WebApp 8.0+ — check version before calling- PNG actually works — Despite docs suggesting JPEG/GIF, PNG renders fine in inline results
- Callback returns falsy even on success — use truthy check
if (sent)not=== true - JPEG recommended for share cards — Smaller file size, faster loading in chat
- photo_url must be publicly accessible — Use R2 public bucket or similar CDN
Two-Button Pattern (ClawdFessions approach): For apps where you want both quick sharing AND rich custom cards:
<div className="flex gap-1">
{/* Quick Share - uses static prepared message, opens native picker */}
<button onClick={handleQuickShare}>⚡</button>
{/* Share Card - sends rich image to bot chat, user forwards manually */}
<button onClick={handleShareCard}>🎨</button>
</div>
- Quick Share: Native picker, faster, less friction
- Share Card: Custom generated image, richer but requires manual forward
6. Generating Share Cards (Server-Side Images)
The Problem: You want custom share card images generated dynamically per item.
Solution: resvg-wasm in Cloudflare Workers Generate SVG → convert to PNG/JPEG server-side:
import { Resvg, initWasm } from '@resvg/resvg-wasm';
import resvgWasm from './resvg.wasm';
let initialized = false;
async function ensureWasm() {
if (!initialized) {
await initWasm(resvgWasm);
initialized = true;
}
}
async function svgToJpeg(svg: string): Promise<Uint8Array> {
await ensureWasm();
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: 800 },
font: {
fontBuffers: [fontData], // Bundle your fonts
defaultFontFamily: 'Inter',
},
});
const pixels = resvg.render();
return encodeJpeg(pixels); // Use jpeg-js or similar
}
Tips:
- Bundle fonts as base64 or binary — Google Fonts won't load at runtime
- Cache generated images in R2 with content hash keys
- 800px width is good for Telegram inline results
- JPEG for photos, PNG if you need transparency
R2 Caching Pattern:
// Check cache first
const cacheKey = `cards/${confessionId}.jpg`;
const cached = await env.CARDS.get(cacheKey);
if (cached) return new Response(cached.body, { headers: { 'Content-Type': 'image/jpeg' } });
// Generate and cache
const jpeg = await generateCard(confession);
await env.CARDS.put(cacheKey, jpeg, { httpMetadata: { contentType: 'image/jpeg' } });
return new Response(jpeg, { headers: { 'Content-Type': 'image/jpeg' } });
🟡 Common Gotchas
API Image Paths
Different APIs return images in different paths. Always use fallback chains:
const imageUrl = item.preview?.image256
|| item.collection?.preview?.image256
|| item.image
|| '/placeholder.png';
GetGems API
- Only supports
limit, no pagination/offset - Rate limit: 400 requests / 5 minutes per IP
- Volume data is all-time, not 24h
- USDT listings exist — detect by token address
Fragment API
- Number images:
https://nft.fragment.com/number/{11-digit-number}.webp - Username images: via separate API call
Theme Colors
Use CSS variables for Telegram theme integration:
background: var(--tg-theme-bg-color, #0f0f1a);
color: var(--tg-theme-text-color, #fff);
🟢 Best Practices
Debug Overlay
Add a dev-only panel showing platform info:
function DebugOverlay() {
if (!isDev) return null;
const webApp = window.Telegram?.WebApp;
return (
<div className="fixed bottom-4 right-4 bg-black/80 p-2 text-xs">
<div>Platform: {webApp?.platform}</div>
<div>Fullscreen: {webApp?.isFullscreen ? 'Y' : 'N'}</div>
<div>Safe top: {webApp?.safeAreaInset?.top}</div>
<div>Content safe top: {webApp?.contentSafeAreaInset?.top}</div>
</div>
);
}
Testing Checklist
- Open from folder (chat list)
- Open from direct chat
- Open from inline button
- Test on iOS
- Test on Android
- Test share flow
- Test back button
- Scroll with sticky header
Deployment
# Always staging first
npx wrangler pages deploy dist --project-name myapp-staging
# Production only after testing
npx wrangler pages deploy dist --project-name myapp
📁 Component Library
Reusable components at ~/clawd/projects/tg-miniapp-components/:
useSafeAreaInset()— Reactive safe area hookuseFullscreen()— Fullscreen state + controlSafeAreaHeader— Sticky header with safe area handlingDebugOverlay— Dev panel for debugging
📚 Resources
- Telegram Mini Apps Docs
- @telegram-apps/sdk
- Project PLAYBOOK files contain project-specific learnings
Last updated: 2026-02-02 Compiled from sessions: 2026-01-27 through 2026-02-02