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:

1. Reads window.Telegram.WebApp (with @twa-dev/sdk as a typed wrapper). 2. Sends initData to a PHP backend that validates the HMAC-SHA-256 signature against the bot token. 3. Wires MainButton to 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:

- initData is the raw query string (auth_date=...&user=...&hash=...). This is what you send to the backend. The backend recomputes the HMAC and compares it with hash using your bot token as the secret. - initDataUnsafe.user is an already-parsed object. Convenient, but you must not trust any field in it for authorization. Anyone can craft a page that sets window.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 offClick in the cleanup function so useEffect reruns 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 with disable() 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.setText accepts 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_id in an HttpOnly cookie or a short-lived JWT; do not store it in localStorage if you can avoid it. - If your Mini App is launched from a startapp parameter or a start deep link, read WebApp.initDataUnsafe.start_param after 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.initDataUnsafe fields 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.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.