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.
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.
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>
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.
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
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>;
}
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 } }.
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}`));
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.
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.
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' });
}
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).
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();
}
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.
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
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.
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
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.
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):
https://app.gitluke.dev)Cara 2 — Direct Link / Main Mini App (buka via t.me/botusername/appname):
/newapp → pilih bott.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.
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.
Scar dari bikin 9router miniapp beneran. Lo gak perlu ngulang.
Telegram cuma mau URL https://. http://localhost atau IP gak akan jalan di Mini App. Selalu lewat tunnel/reverse-proxy dengan TLS.
window.Telegram.WebApp.initData cuma keisi kalau dibuka DARI dalam Telegram. Buka URL langsung di Chrome → kosong → auth 401. Itu normal, bukan bug.
Tanpa WebApp.ready(), Telegram nganggep app belum siap — loading spinner muter terus. Panggil di useEffect paling awal.
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.
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.
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.
app.get('*') harus didaftar PALING BAWAH, sesudah semua route /api/*. Kalau di atas, semua request API ke-redirect ke index.html.
Telegram cache agresif. Abis redeploy, force reload Mini App / clear cache, kalau enggak lo bingung kenapa update gak muncul.