Build a Telegram Mini App with React: initData validation, @twa-dev/sdk, and MainButton
A Telegram Mini App is a web page that runs inside Telegram's in-app browser on iOS, Android, and desktop. From React's perspective it is just a regular SPA; the differences are the JS bridge (window.Telegram.WebApp), the way you authenticate the user (signing initData on the backend), and the native-feeling UI elements (MainButton, BackButton, theme variables).
In this tutorial we build a small Vite + React Mini App that:
- Reads
window.Telegram.WebApp(with@twa-dev/sdkas a typed wrapper). - Sends
initDatato a PHP backend that validates the HMAC-SHA-256 signature against the bot token. - Wires
MainButtonto a React handler so the native bottom button drives the React state.
We will not cover publishing to Telegram, the tapps.co catalog, or BackButton history — only what the title promises.
1. Bootstrap the project
npm create vite@latest miniapp-react -- --template react-ts
cd miniapp-react
npm install @twa-dev/sdk
npm run dev
For local testing against Telegram you need HTTPS and a public URL; npm run dev with ngrok http 5173 is the usual setup. The official docs explain the tunnel.
2. window.Telegram.WebApp vs @twa-dev/sdk
When Telegram opens your URL it injects a script that exposes a global:
interface TelegramWebApp {
initData: string; // raw query string, used for backend auth
initDataUnsafe: WebAppUser; // already-parsed user object, UNTRUSTED
ready(): void; // tell Telegram the UI is mounted
expand(): void; // grow to full height
close(): void;
MainButton: {
text: string;
show(): void;
hide(): void;
onClick(cb: () => void): void;
offClick(cb: () => void): void;
setText(t: string): void;
enable(): void;
disable(): void;
};
colorScheme: 'light' | 'dark';
themeParams: Record<string, string>;
}
Two properties look similar but are not interchangeable:
-
initDatais the raw query string (auth_date=...&user=...&hash=...). This is what you send to the backend. The backend recomputes the HMAC and compares it withhashusing your bot token as the secret. -
initDataUnsafe.useris an already-parsed object. Convenient, but you must not trust any field in it for authorization. Anyone can craft a page that setswindow.Telegram = { WebApp: { initDataUnsafe: { user: { id: 42 } } } }. Treat it as a UX hint, not identity.
Using @twa-dev/sdk gives you the same API with TypeScript types and a small package boundary, so your React code does not reach into window directly:
import WebApp from '@twa-dev/sdk';
WebApp.ready();
WebApp.expand();
console.log(WebApp.initData); // raw string
console.log(WebApp.initDataUnsafe); // typed object
For the rest of the tutorial we use the SDK; swapping to the global is just removing the import.
3. Validate initData on the backend
This is the step that actually authenticates the user. The contract is documented by Telegram: take every initData field except hash, build a data-check-string of key=value lines joined by \n, compute HMAC-SHA-256(data-check-string, "WebAppData") with the key being SHA-256(bot_token), and compare with hash using a timing-safe comparator. Reject anything older than ~5 minutes by checking auth_date.
A minimal PHP endpoint:
<?php
// public/auth.php
declare(strict_types=1);
header('Content-Type: application/json');
$raw = file_get_contents('php://input') ?: '';
$payload = json_decode($raw, true);
if (!is_array($payload) || !isset($payload['initData'])) {
http_response_code(400);
echo json_encode(['error' => 'initData missing']);
return;
}
$botToken = getenv('BOT_TOKEN'); // never hardcode
$secret = hash('sha256', $botToken, true);
parse_str($payload['initData'], $data);
if (!isset($data['hash'], $data['auth_date'], $data['user'])) {
http_response_code(400);
echo json_encode(['error' => 'malformed initData']);
return;
}
$check = [];
foreach ($data as $k => $v) {
if ($k === 'hash') continue;
$check[] = $k . '=' . $v;
}
$checkString = implode("\n", $check);
$calc = hash_hmac('sha256', $checkString, $secret);
if (!hash_equals($calc, (string) $data['hash'])) {
http_response_code(401);
echo json_encode(['error' => 'bad signature']);
return;
}
if (time() - (int) $data['auth_date'] > 300) {
http_response_code(401);
echo json_encode(['error' => 'initData expired']);
return;
}
$user = json_decode($data['user'], true);
$tid = is_array($user) && isset($user['id']) ? (int) $user['id'] : 0;
// At this point you have a verified telegram_id.
// Bind it to your local session/JWT and respond.
echo json_encode(['ok' => true, 'telegram_id' => $tid]);
Key points: parse_str handles URL-decoding, hash_equals is the timing-safe comparator, the bot token stays in an env var, and auth_date gives you a replay window. The earlier DEV.to article on HMAC validation walks through the same algorithm in more depth — treat this as the React-side companion.
4. React side: call the backend
Keep the call small and typed. We only need initData; initDataUnsafe is a UX hint we display, never a source of truth.
// src/api.ts
export type AuthUser = { id: number; first_name: string; username?: string };
export async function authWithTelegram(initData: string): Promise<AuthUser> {
const res = await fetch('/auth.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData }),
});
if (!res.ok) throw new Error(`auth failed: ${res.status}`);
const json = await res.json();
return json.user as AuthUser;
}
Call it once when the component mounts, after WebApp.ready():
// src/App.tsx
import { useEffect, useState } from 'react';
import WebApp from '@twa-dev/sdk';
import { authWithTelegram, AuthUser } from './api';
export default function App() {
const [user, setUser] = useState<AuthUser | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
WebApp.ready();
authWithTelegram(WebApp.initData)
.then(setUser)
.catch((e) => setError(String(e)));
}, []);
if (error) return <p>Auth error: {error}</p>;
if (!user) return <p>Loading…</p>;
return <p>Hello, {user.first_name} ({user.id})</p>;
}
WebApp.initDataUnsafe.user is available immediately, so you can show the name while the network request is in flight — just remember that any field coming from it is untrusted until the backend confirms.
5. Wire MainButton to React state
MainButton is the persistent button at the bottom of the Mini App. Two rules keep it sane:
- Configure it inside React effects, not during render.
- Use
offClickin the cleanup function souseEffectreruns do not stack handlers.
import { useEffect, useState } from 'react';
import WebApp from '@twa-dev/sdk';
export function ConfirmButton({ onConfirm }: { onConfirm: () => void }) {
const [busy, setBusy] = useState(false);
useEffect(() => {
const mb = WebApp.MainButton;
mb.text = 'CONFIRM';
mb.show();
const handler = async () => {
if (busy) return;
setBusy(true);
mb.showProgress(true);
try {
await onConfirm();
WebApp.close();
} finally {
mb.showProgress(false);
setBusy(false);
}
};
mb.onClick(handler);
return () => {
mb.offClick(handler);
mb.hide();
};
}, [onConfirm, busy]);
return null;
}
A few things worth knowing:
-
MainButton.showProgress(true)shows the indeterminate spinner; pair it withdisable()if you also want to ignore further clicks. -
WebApp.close()is the only way to close a Mini App from JS. Do not try to navigate away; the in-app browser will just reopen you. -
MainButton.setTextaccepts up to 64 visible characters; longer strings truncate.
6. Production notes (optional)
- Use a relative path for the backend and serve the SPA + the API from the same origin, otherwise Telegram's CSP will block the fetch.
- Pin a
v=query string or a build hash on your HTML so Telegram does not cache a stale shell after deploys. - Cache the verified
telegram_idin an HttpOnly cookie or a short-lived JWT; do not store it inlocalStorageif you can avoid it. - If your Mini App is launched from a
startappparameter or astartdeep link, readWebApp.initDataUnsafe.start_paramafter auth and dispatch on it — but still gate the action server-side. - For analytics, count events on the backend keyed by the verified
telegram_id.WebApp.initDataUnsafefields are fine for "which button did they press" funnels, never for billing.
If you want to skip the boilerplate and ship a Mini App alongside a Telegram bot, BotCreator ships end-to-end product work — bot, Mini App, and backend. The Telegram Bot API reference they maintain at botservice.biz/telegram-bot-api is a good companion while you read the official docs.
Top comments (0)