TUTORIAL T-003 · TELEGRAM MINI APP · FULL-STACK

Bikin Telegram Mini App dari Nol

Studi kasus real: 9router miniapp — dashboard mobile-first yang nge-proxy ke API 9router live (localhost:20128) dan jalan di dalam Telegram. React 19 + Vite + TypeScript di frontend, Express reverse-proxy di backend, auth pakai password gate + Telegram initData HMAC. Dari npm create sampai live di BotFather.

STACK: React 19 + Express STEPS: 09 LEVEL: Intermediate DEPLOY: CF Tunnel
— TL;DR

Scaffold Vite React-TS → build UI mobile-first pakai Telegram WebApp SDK (window.Telegram.WebApp) → bikin Express backend yang serve static + expose API → amankan dengan verifikasi initData HMAC-SHA256 (cek bot_token) → build frontend (npm run build) → expose via cloudflared tunnel → daftarin URL HTTPS ke @BotFather (Menu Button). Mini App = web app biasa yang jalan di webview Telegram dengan SDK + auth khusus.

STEP 01

Apa Itu Mini App & Scaffold Project

Telegram Mini App itu web app biasa (HTML/JS) yang dirender di dalam webview Telegram. Bedanya cuma dua: ada Telegram WebApp SDK (window.Telegram.WebApp) buat akses tema, viewport, & data user, plus mekanisme auth lewat initData yang ditandatangani bot token. Selebihnya React/Vite biasa.

Scaffold pakai Vite template React + TypeScript:

# Scaffold Vite React-TS $ npm create vite@latest 9router-miniapp -- --template react-ts $ cd 9router-miniapp $ npm install # Jalanin dev server $ npm run dev # http://localhost:5173

Tambah SDK Telegram ke index.html — ini yang bikin window.Telegram.WebApp tersedia:

<!-- index.html, di dalam <head> --> <script src="https://telegram.org/js/telegram-web-app.js"></script>
— KENAPA FULL-STACK

Mini App murni frontend cuma cukup buat UI statis. Begitu lo butuh akses data sensitif (API key, stats, OAuth token) ATAU verifikasi user beneran login dari Telegram, lo WAJIB punya backend. Di case 9router: backend jadi reverse-proxy ke API 9router live, mint JWT server-side, dan gak pernah ekspos token mentah ke browser.

STEP 02

Struktur Project (Frontend + Backend)

Pisahin frontend (React/Vite) dan backend (Express) dalam satu repo. Backend nanti yang serve hasil build frontend + expose API.

9router-miniapp/ ├── src/ # Frontend: React 19 + Vite + TS │ ├── config.ts # APP_NAME, API_BASE │ ├── types.ts # Shared TS types (mirror server) │ ├── App.tsx # Tab nav + auth gate │ ├── lib/api.ts # Fetch wrapper (+ initData header) │ ├── components/ # Login, Toast │ └── pages/ # Home, Providers, Logs, Settings, dst ├── server/ # Backend: Express │ ├── index.js # API + static file serving │ └── lib/ │ ├── telegram-auth.js # initData HMAC verify + middleware │ ├── gate.js # Password gate + session token (HMAC cookie) │ └── auth.js # Mint JWT buat upstream 9router ├── public/ # favicon, icons ├── .env.example └── vite.config.ts

Init backend sebagai package terpisah:

$ mkdir server && cd server $ npm init -y $ npm install express cors dotenv jose http-proxy-middleware
STEP 03

Frontend: Tab Nav + Telegram SDK Init

Inti UI: panggil WebApp.ready() & WebApp.expand() di mount, cek auth status, lalu render tab navigation. Ini pola di App.tsx.

// src/App.tsx import { useState, useEffect } from 'react'; import Home from './pages/Home'; import Login from './components/Login'; const TABS = [ { id: 'home', label: 'Overview' }, { id: 'providers', label: 'Providers' }, { id: 'logs', label: 'Logs' }, { id: 'settings', label: 'Settings' }, ]; function App() { const [tab, setTab] = useState('home'); const [authState, setAuthState] = useState<'checking' | 'login' | 'ok'>('checking'); useEffect(() => { // Wajib: kasih tau Telegram app siap, lalu fullscreen window.Telegram?.WebApp?.ready(); window.Telegram?.WebApp?.expand(); checkAuth(); }, []); const checkAuth = async () => { try { const r = await fetch('/api/auth-status', { credentials: 'include' }); const d = await r.json(); setAuthState(!d.required || d.authed ? 'ok' : 'login'); } catch { setAuthState('login'); } }; if (authState === 'login') return <Login onAuth={checkAuth} />; return <div>/* tab nav + page switch */</div>; }
— PITFALL: TypeScript & window.Telegram

TS bakal error Property 'Telegram' does not exist on Window. Solusi cepat: // @ts-ignore di atas tiap akses, atau (lebih rapi) deklarasi global di types.ts: declare global { interface Window { Telegram?: any } }.

STEP 04

Backend: Express Serve Static + API

Backend punya 3 tugas: (1) serve hasil build frontend (folder dist/), (2) handle auth (login/session), (3) reverse-proxy request ke API 9router live sambil nyuntik JWT. Satu server, satu port — gampang di-deploy.

// server/index.js import express from 'express'; import cors from 'cors'; import { createProxyMiddleware } from 'http-proxy-middleware'; import dotenv from 'dotenv'; import { getAuthToken } from './lib/auth.js'; import { telegramAuthMiddleware } from './lib/telegram-auth.js'; import { passwordGate, makeSessionToken } from './lib/gate.js'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.resolve(__dirname, '../.env') }); const app = express(); const PORT = parseInt(process.env.PORT || '9122'); const NROUTER_URL = process.env.NROUTER_URL || 'http://localhost:20128'; app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); app.use('/api', express.json()); // JSON parsing HANYA buat /api (jangan /proxy) // Health + login + auth-status (no auth) — lihat Step 06 detail gate-nya app.get('/api/health', (req, res) => res.json({ status: 'ok', upstream: NROUTER_URL })); // --- Proxy chain: password gate -> telegram gate -> mint JWT -> forward --- app.use('/proxy', passwordGate); app.use('/proxy', telegramAuthMiddleware); app.use('/proxy', async (req, res, next) => { try { req._nrouterToken = await getAuthToken(); next(); } catch (e) { res.status(500).json({ error: 'Auth token generation failed' }); } }); app.use('/proxy', createProxyMiddleware({ target: NROUTER_URL, changeOrigin: true, pathRewrite: { '^/proxy': '' }, on: { proxyReq: (proxyReq, req) => { if (req._nrouterToken) proxyReq.setHeader('Cookie', `auth_token=${req._nrouterToken}`); proxyReq.removeHeader('authorization'); }, }, })); // --- Serve frontend build (dist/) untuk semua route lain --- const dist = path.resolve(__dirname, '../dist'); app.use(express.static(dist)); app.get('*', (req, res) => res.sendFile(path.join(dist, 'index.html'))); app.listen(PORT, '0.0.0.0', () => console.log(`Mini App di :${PORT} -> ${NROUTER_URL}`));
— KENAPA REVERSE-PROXY, BUKAN BACA DB

9router udah punya REST API lengkap di localhost:20128. Daripada baca SQLite-nya langsung (rapuh, schema bisa berubah antar versi), miniapp cukup proxy ke API itu. Backend nyuntik JWT (Step berikutnya) jadi browser gak pernah pegang kredensial upstream. Frontend tinggal hit /proxy/api/... seolah ngomong langsung ke 9router.

— PITFALL: JSON parser nelan body proxy

Pasang express.json() CUMA di /api, JANGAN global. Kalau global, dia bakal consume request body sebelum sampai ke proxy middleware → POST/PUT ke upstream jadi kosong. Dan SPA fallback app.get('*') harus PALING BAWAH.

STEP 05

JWT Mint + Password Gate (Server-Side Secrets)

Ini jantung keamanan miniapp. 9router butuh auth cookie (auth_token) buat tiap request ke API-nya. Daripada nyimpen kredensial itu di browser, backend mint JWT sendiri pakai secret 9router yang ada di server, lalu nyuntiknya pas proxy. Browser gak pernah liat secret apa pun.

// server/lib/auth.js — mint JWT buat upstream 9router import { SignJWT } from 'jose'; import fs from 'fs'; let cachedToken = null, tokenExpiry = 0; function getSecret() { const p = process.env.NROUTER_JWT_SECRET_PATH || '/root/.9router/jwt-secret'; return new TextEncoder().encode(fs.readFileSync(p, 'utf8').trim()); } export async function getAuthToken() { const now = Date.now(); // Cache token, refresh 5 menit sebelum expiry if (cachedToken && tokenExpiry - now > 5 * 60 * 1000) return cachedToken; cachedToken = await new SignJWT({ authenticated: true }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('23h') .sign(getSecret()); tokenExpiry = now + 23 * 60 * 60 * 1000; return cachedToken; }

Lapisan kedua: password gate (gate.js). Session token = HMAC(password, server-secret) — disimpen di cookie HttpOnly. Attacker gak bisa forge tanpa tau password.

// server/lib/gate.js — session token via HMAC import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import os from 'os'; // Secret di-persist biar restart gak nge-invalidate session function getSessionSecret() { const file = path.join(os.homedir(), '.9router', 'miniapp-session-secret'); try { return fs.readFileSync(file, 'utf8').trim(); } catch { const s = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(file, s, { mode: 0o600 }); return s; } } const SECRET = getSessionSecret(); export function makeSessionToken(password) { return crypto.createHmac('sha256', SECRET).update(password).digest('hex'); } // Middleware: tolak kalau APP_PASSWORD di-set tapi cookie session gak valid export function passwordGate(req, res, next) { const pw = process.env.APP_PASSWORD; if (!pw) return next(); // password opsional const cookie = (req.headers.cookie || '').match(/mini_session=([^;]+)/); if (cookie && cookie[1] === makeSessionToken(pw)) return next(); res.status(401).json({ error: 'auth_required' }); }
— PRINSIP

Aturan emas: secret hidup di server, gak pernah ke client. Frontend cuma kirim password lewat /api/login (HTTPS) → dapet cookie session. Token JWT buat upstream di-mint & di-cache server-side. Kalau lo taruh secret di browser terus di-hide via JS, itu tetap bocor (buka DevTools).

STEP 06

Auth: Verifikasi Telegram initData (HMAC-SHA256)

Ini yang bikin Mini App beda dari web app biasa. Telegram ngasih string initData ke webview (berisi data user + hash). Server lo verifikasi hash itu pakai bot token — kalau valid, berarti request beneran dari Telegram dan user-nya asli. Gak bisa dipalsuin tanpa bot token.

Algoritmanya per dokumentasi resmi Telegram:

// server/lib/telegram-auth.js import crypto from 'crypto'; export function verifyTelegramInitData(initData, botToken) { if (!initData || !botToken) return null; const params = new URLSearchParams(initData); const hash = params.get('hash'); if (!hash) return null; // 1. Susun data-check-string: semua param kecuali hash, sorted, join \n const checkArr = []; for (const [key, val] of params.entries()) { if (key !== 'hash') checkArr.push(`${key}=${val}`); } checkArr.sort(); const dataCheckString = checkArr.join('\n'); // 2. secret_key = HMAC_SHA256("WebAppData", bot_token) const secretKey = crypto.createHmac('sha256', 'WebAppData') .update(botToken).digest(); // 3. computed = HMAC_SHA256(secret_key, dataCheckString) — bandingin sama hash const computed = crypto.createHmac('sha256', secretKey) .update(dataCheckString).digest('hex'); if (computed !== hash) return null; // PALSU — tolak const user = JSON.parse(params.get('user') || '{}'); return { id: user.id, username: user.username, firstName: user.first_name }; }

Bungkus jadi middleware Express. Header format Authorization: tma <initData>, plus whitelist Telegram ID (opsional, biar cuma lo yang bisa akses):

export function telegramAuthMiddleware(req, res, next) { const botToken = process.env.MINIAPP_BOT_TOKEN; const allowed = (process.env.ALLOWED_TG_IDS || '') .split(',').map(s => parseInt(s.trim())).filter(Boolean); // Dev mode: tanpa bot token, skip auth (CUMA buat lokal) if (!botToken) { console.warn('[auth] no MINIAPP_BOT_TOKEN — DEV MODE tanpa auth'); return next(); } const h = req.headers['authorization'] || ''; const initData = h.startsWith('tma ') ? h.slice(4) : null; const user = verifyTelegramInitData(initData, botToken); if (!user) return res.status(401).json({ error: 'Invalid Telegram initData' }); if (allowed.length && !allowed.includes(user.id)) return res.status(403).json({ error: 'Not whitelisted' }); req.tgUser = user; next(); }
— PITFALL: Dev mode bocor ke prod

Kalau MINIAPP_BOT_TOKEN gak di-set, middleware skip auth total. Enak buat dev lokal, BAHAYA di production — siapa aja bisa hit API lo. Pastiin .env production selalu punya bot token + ALLOWED_TG_IDS. Jangan pernah deploy tanpa dua itu.

STEP 07

Frontend: Kirim initData ke Tiap Request

Frontend hit backend lewat base /proxy/api (di-rewrite proxy ke API 9router). Tiap request bawa cookie session (dari login) + selipin initData kalau ada. Bungkus dalam satu wrapper biar gak repeat.

// src/config.ts export const API_BASE = '/proxy/api'; // di-rewrite backend -> 9router // src/lib/api.ts import { API_BASE } from '../config'; function getInitData(): string { // @ts-ignore return window.Telegram?.WebApp?.initData || ''; } async function request<T>(ep: string, opts: RequestInit = {}, raw = false): Promise<T> { const initData = getInitData(); const headers: Record<string, string> = { ...opts.headers as any }; if (initData) headers['Authorization'] = `tma ${initData}`; // "tma <initData>" const url = raw ? ep : `${API_BASE}${ep}`; const res = await fetch(url, { ...opts, headers, credentials: 'include' }); if (!res.ok) { if (res.status === 401) window.dispatchEvent(new CustomEvent('auth_required')); // trigger login throw new Error(`HTTP ${res.status}`); } return res.json(); } // API helper object export const api = { get: <T>(ep: string) => request<T>(ep), post: <T>(ep: string, body?: any) => request<T>(ep, { method: 'POST', body: JSON.stringify(body) }), rawPost: <T>(p: string, body?: any) => request<T>(p, { method: 'POST', body: JSON.stringify(body) }, true), }; // Login (raw, ke /api/login bukan /proxy): await api.rawPost('/api/login', { password }) // Data: const d = await api.get<Overview>('/overview'); // -> /proxy/api/overview
— DUA JALUR AUTH

Ada 2 cara akses: (1) password gate — buka di browser biasa, login lewat /api/login dapet cookie session. (2) Telegram initData — buka dari dalam Telegram, initData ke-inject otomatis & di-verify backend. Di luar Telegram initData kosong, jadi password jadi fallback. Event auth_required (dari response 401) yang trigger UI login muncul.

STEP 08

Build & Deploy via Cloudflare Tunnel

Telegram WAJIB butuh URL HTTPS buat Mini App. Cara paling cepet tanpa beli domain/VPS: cloudflared tunnel — expose server lokal lo ke URL HTTPS publik instan.

Build frontend dulu, jalanin server, baru tunnel:

# 1. Build frontend → hasil ke dist/ $ npm run build # 2. Set env production (server/.env) $ cat > server/.env <<EOF PORT=9122 NROUTER_URL=http://localhost:20128 APP_PASSWORD=ganti-password-kuat NROUTER_JWT_SECRET_PATH=/root/.9router/jwt-secret MINIAPP_BOT_TOKEN=123456:ABC-your-bot-token ALLOWED_TG_IDS=123456789 EOF # 3. Jalanin server $ cd server && node index.js Mini App di http://localhost:9122 # 4. Tunnel (terminal lain) — dapet URL HTTPS instan $ cloudflared tunnel --url http://localhost:9122 https://random-words-1234.trycloudflare.com ← ini URL miniapp lo

Buat production permanen (bukan URL random tiap restart), pakai named tunnel + domain lo sendiri:

$ cloudflared tunnel login $ cloudflared tunnel create miniapp $ cloudflared tunnel route dns miniapp app.gitluke.dev $ cloudflared tunnel run --url http://localhost:9122 miniapp
— ALTERNATIF: Caddy / systemd

Kalau punya VPS dengan domain, pakai Caddy reverse proxy (auto-HTTPS): app.gitluke.dev { reverse_proxy 127.0.0.1:9122 }. Dan jadiin server-nya systemd service biar auto-restart — jangan jalan manual di production.

STEP 09

Daftarin ke BotFather (Go Live)

Langkah terakhir: hubungkan URL HTTPS ke bot Telegram lo. Ada 2 cara nampilin Mini App ke user.

Cara 1 — Menu Button (tombol di pojok kiri input chat, paling umum):

  1. Buka @BotFather
  2. Pilih bot lo → Bot SettingsMenu ButtonConfigure menu button
  3. Paste URL HTTPS (e.g. https://app.gitluke.dev)
  4. Kasih label tombol (e.g. "Dashboard")

Cara 2 — Direct Link / Main Mini App (buka via t.me/botusername/appname):

  1. @BotFather → /newapp → pilih bot
  2. Isi title, description, photo (640×360), URL HTTPS
  3. Set short name → dapet link t.me/yourbot/yourapp
# Test cepet: buka bot lo di Telegram, klik Menu Button. # Mini App lo render full-screen di dalam Telegram. # initData otomatis ke-inject → auth jalan → dashboard tampil.
— PITFALL: Cache webview

Telegram nge-cache webview agresif. Abis update & redeploy, kadang masih nampilin versi lama. Force refresh: tutup-buka Mini App, atau Settings Telegram → clear cache. Di desktop, klik kanan dalam Mini App → Reload.

— COMMON PITFALLS

Things That Break

Scar dari bikin 9router miniapp beneran. Lo gak perlu ngulang.

HTTPS wajib, HTTP ditolak

Telegram cuma mau URL https://. http://localhost atau IP gak akan jalan di Mini App. Selalu lewat tunnel/reverse-proxy dengan TLS.

initData kosong di luar Telegram

window.Telegram.WebApp.initData cuma keisi kalau dibuka DARI dalam Telegram. Buka URL langsung di Chrome → kosong → auth 401. Itu normal, bukan bug.

Lupa WebApp.ready()

Tanpa WebApp.ready(), Telegram nganggep app belum siap — loading spinner muter terus. Panggil di useEffect paling awal.

Dev-mode auth bocor ke production

Backend skip auth kalau MINIAPP_BOT_TOKEN gak ada. Aman di lokal, fatal di prod — API lo telanjang. Selalu set bot token + ALLOWED_TG_IDS di .env production.

Secret upstream ke-leak ke client

Jangan pernah kirim JWT secret / auth cookie ke browser. Mint JWT di server (auth.js), suntik pas proxy via proxyReq.setHeader('Cookie', ...). Browser cukup pegang cookie session HMAC, bukan kredensial upstream.

express.json() global nelan body proxy

Pasang express.json() CUMA di /api. Kalau global, body request ke-consume sebelum sampai http-proxy-middleware → POST/PUT ke upstream jadi kosong & 9router nolak.

SPA fallback nelen route API

app.get('*') harus didaftar PALING BAWAH, sesudah semua route /api/*. Kalau di atas, semua request API ke-redirect ke index.html.

Webview cache versi lama

Telegram cache agresif. Abis redeploy, force reload Mini App / clear cache, kalau enggak lo bingung kenapa update gak muncul.