# 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:**
- `safeAreaInset` and `contentSafeAreaInset` can 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:**
```typescript
// 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:**
```tsx
// WRONG - content shows through gap
Header
// CORRECT - background covers full safe area
Header
```
---
### 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`:
```tsx
import { createPortal } from 'react-dom';
function Modal({ children }) {
return createPortal(
{children}
,
document.body
);
}
```
---
### 3. React Renders "0" as Text
**The Problem:** `{value && value > 0 && }` renders literal "0" when value is 0.
**The Solution:**
```tsx
// WRONG
{count && count > 0 && {count}}
// CORRECT
{count != null && count > 0 && {count}}
// OR
{count > 0 && {count}}
// OR
{!!count && {count}}
```
---
### 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`:
```typescript
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:**
1. Bot must have **inline mode enabled** via @BotFather (`/setinline`)
2. Must call `savePreparedInlineMessage` on backend first
3. Pass the `prepared_message_id` to `shareMessage()`
**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:
```typescript
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:
```typescript
// 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:**
1. Try native `shareMessage` (requires inline mode)
2. Try sending image to bot chat (user forwards manually)
3. Fall back to text share via `web_app_open_tg_link`
**Known Behaviors (2026-02):**
- `shareMessage` requires **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:
```tsx