Shipping an e-commerce storefront inside a Telegram Mini App using Next.js App Router seems straightforward until untrusted client state compromises your checkout process. Front-end developers frequently rely on window.Telegram.WebApp.initDataUnsafe to identify the user and send cart totals directly from the client to an API route. Because initDataUnsafe can be mutated by anyone opening your app in a standard browser or using browser developer tools, accepting unverified user IDs or total prices from the front end allows users to modify invoice amounts, impersonate other customer accounts, or execute unverified orders.
Building a robust Telegram Mini App shop requires verifying the raw initData payload using an HMAC-SHA256 signature on the server, calculating cart prices strictly from stored backend database values, controlling Telegram's native MainButton across React render lifecycles, and accounting for renderer discrepancies between iOS WebViews and Telegram Desktop.
Validating Telegram initData in Next.js Route Handlers
When a client triggers a checkout flow, the application must pass the raw window.Telegram.WebApp.initData query string to a Next.js App Router Route Handler (app/api/checkout/route.ts). Never pass pre-parsed JSON objects from initDataUnsafe. The raw string contains key-value pairs sorted alphabetically and signed by Telegram using a secret key derived from your Bot Token.
If your API route receives a list of product IDs and quantities, it must recalculate the total price on the server side using your application's product catalog. Once verified, the route calls Telegram's createInvoiceLink Bot API endpoint to return a native payment link back to the front end.
Here is how to validate initData and generate an invoice link securely in a Next.js Route Handler:
// app/api/checkout/route.ts import { NextResponse } from 'next/server'; import crypto from 'crypto'; interface CartItem { id: string; quantity: number; } const PRODUCT_CATALOG: Record<string, { name: string; priceInCents: number }> = { 'item_1': { name: 'Developer Mug', priceInCents: 1500 }, 'item_2': { name: 'Mechanical Keyboard', priceInCents: 12000 }, }; function verifyTelegramInitData(initDataRaw: string, botToken: string) { const params = new URLSearchParams(initDataRaw); const hash = params.get('hash'); if (!hash) return { isValid: false, user: null }; params.delete('hash'); const dataCheckArray: string[] = []; for (const [key, value] of params.entries()) { dataCheckArray.push(`${key}=${value}`); } dataCheckArray.sort(); const dataCheckString = dataCheckArray.join('\n'); const secretKey = crypto .createHmac('sha256', 'WebAppData') .update(botToken) .digest(); const calculatedHash = crypto .createHmac('sha256', secretKey) .update(dataCheckString) .digest('hex'); if (calculatedHash !== hash) { return { isValid: false, user: null }; } const authDate = parseInt(params.get('auth_date') || '0', 10); const now = Math.floor(Date.now() / 1000); if (now - authDate > 86400) { return { isValid: false, user: null }; // Reject auth_date older than 24 hours } const userRaw = params.get('user'); const user = userRaw ? JSON.parse(userRaw) : null; return { isValid: true, user }; } export async function POST(request: Request) { try { const body = await request.json(); const { initData, items } = body as { initData: string; items: CartItem[] }; const botToken = process.env.TELEGRAM_BOT_TOKEN; if (!botToken) { return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); } const { isValid, user } = verifyTelegramInitData(initData, botToken); if (!isValid || !user) { return NextResponse.json({ error: 'Invalid signature or expired session' }, { status: 401 }); } // Recalculate cart total on server let totalAmount = 0; const invoicePrices = []; for (const item of items) { const product = PRODUCT_CATALOG[item.id]; if (!product) { return NextResponse.json({ error: `Invalid product ID: ${item.id}` }, { status: 400 }); } const itemTotal = product.priceInCents * item.quantity; totalAmount += itemTotal; invoicePrices.push({ label: `${product.name} x${item.quantity}`, amount: itemTotal, }); } if (totalAmount === 0) { return NextResponse.json({ error: 'Cart is empty' }, { status: 400 }); } // Request invoice link from Telegram Bot API const response = await fetch(`https://api.telegram.org/bot${botToken}/createInvoiceLink`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Order Checkout', description: `Order for Telegram User ID ${user.id}`, payload: JSON.stringify({ userId: user.id, timestamp: Date.now() }), provider_token: process.env.PAYMENT_PROVIDER_TOKEN || '', currency: 'USD', prices: invoicePrices, }), }); const data = await response.json(); if (!data.ok) { return NextResponse.json({ error: data.description || 'Failed to create invoice' }, { status: 500 }); } return NextResponse.json({ invoiceUrl: data.result }); } catch (err) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } If an attacker manipulates the cart payload in the browser or sends a fake HTTP request, the route fails at the signature check stage or re-computes the price from server records. The front end never sends prices; it only sends product identifiers and quantities.
Syncing the MainButton and Cart State in React
Telegram provides a native UI button pinned to the bottom of the Mini App container via Telegram.WebApp.MainButton. Integrating MainButton inside a React dynamic application often introduces event listener memory leaks, duplicate payment requests, or visual desynchronization when cart items change.
A common implementation flaw occurs when developers bind Telegram.WebApp.MainButton.onClick inside a component without unbinding previous listeners on state changes. Every re-render attaches another listener callback, causing a single tap on the button to execute multiple fetch calls to /api/checkout simultaneously.
To prevent this, sync MainButton state inside a dedicated React useEffect block, managing visibility, text, and event listener cleanup explicitly:
// components/CartCheckout.tsx 'use client'; import { useEffect, useState } from 'react'; interface CartItem { id: string; name: string; priceInCents: number; quantity: number; } export default function CartCheckout({ items }: { items: CartItem[] }) { const [isLoading, setIsLoading] = useState(false); const totalCents = items.reduce((sum, item) => sum + item.priceInCents * item.quantity, 0); useEffect(() => { const tg = window.Telegram?.WebApp; if (!tg || !tg.MainButton) return; if (items.length === 0) { tg.MainButton.hide(); return; } tg.MainButton.setText(`CHECKOUT ($${(totalCents / 100).toFixed(2)})`); tg.MainButton.show(); if (isLoading) { tg.MainButton.showProgress(false); tg.MainButton.disable(); } else { tg.MainButton.hideProgress(); tg.MainButton.enable(); } const handleMainButtonClick = async () => { setIsLoading(true); try { const response = await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ initData: tg.initData, items: items.map(i => ({ id: i.id, quantity: i.quantity })), }), }); const result = await response.json(); if (!response.ok) { tg.showAlert(result.error || 'Checkout failed'); return; } // Open native payment modal via invoice link tg.openInvoice(result.invoiceUrl, (status) => { if (status === 'paid') { tg.showAlert('Payment successful!'); // Clear cart or redirect user } else if (status === 'failed') { tg.showAlert('Payment failed or canceled.'); } }); } catch (err) { tg.showAlert('Network error occurred during checkout.'); } finally { setIsLoading(false); } }; tg.MainButton.onClick(handleMainButtonClick); return () => { tg.MainButton.offClick(handleMainButtonClick); }; }, [items, totalCents, isLoading]); return ( <div className="p-4"> <h2 className="text-lg font-bold">Your Cart</h2> {items.length === 0 ? ( <p>Cart is empty.</p> ) : ( <ul className="divide-y"> {items.map((item) => ( <li key={item.id} className="py-2 flex justify-between"> <span>{item.name} x{item.quantity}</span> <span>${((item.priceInCents * item.quantity) / 100).toFixed(2)}</span> </li> ))} </ul> )} </div> ); } Calling tg.MainButton.offClick(handleMainButtonClick) inside the clean-up return statement guarantees that old event listeners are removed whenever items or isLoading changes.
Fixing iOS and Desktop Telegram WebView Render Quirks
Developing Mini Apps in modern browsers does not prepare your interface for Telegram's underlying WebViews. Android, iOS, and Telegram Desktop interpret viewports, color themes, and navigation stack updates in radically different ways.
1. Viewport Height Truncation on iOS
Standard CSS rules like height: 100vh break inside iOS Telegram WebViews. iOS WebViews calculate 100vh based on the full screen size including obscured areas behind native top bars and bottom gesture controls. This causes the bottom portion of your Next.js application—often the cart action area—to render below the visible fold.
Telegram provides window.Telegram.WebApp.viewportStableHeight and CSS variables injected directly into the document root. Set up your layout using dynamic CSS custom properties rather than hardcoded 100vh rules.
2. BackButton Routing Synchronization
Next.js App Router relies on next/navigation to manage client routing. However, Telegram native apps expect the top-left BackButton to open and close depending on view depth. If the user navigates from the product catalog into a detail page, your React code must explicitly call Telegram.WebApp.BackButton.show() and register a click listener that calls router.back().
3. Desktop Theme Mismatches
Telegram Desktop allows users to set custom light, dark, and high-contrast themes. If your application relies exclusively on standard Tailwind dark modes or prefers-color-scheme, the Mini App interface will clash with Telegram's surrounding window chrome. Telegram passes current color tokens through window.Telegram.WebApp.themeParams.
Here is a dedicated Client Component setup that resolves viewport height calculations, binds BackButton to Next.js navigation, and syncs color themes:
// components/TelegramInitializer.tsx 'use client'; import { useEffect } from 'react'; import { useRouter, usePathname } from 'next/navigation'; declare global { interface Window { Telegram?: { WebApp?: any; }; } } export default function TelegramInitializer() { const router = useRouter(); const pathname = usePathname(); useEffect(() => { const tg = window.Telegram?.WebApp; if (!tg) return; // Tell Telegram client the Mini App is initialized tg.ready(); tg.expand(); // Fix iOS viewport height bug using native dynamic viewport variables const updateViewport = () => { if (tg.viewportStableHeight) { document.documentElement.style.setProperty( '--tg-viewport-height', `${tg.viewportStableHeight}px` ); } }; tg.onEvent('viewportChanged', updateViewport); updateViewport(); return () => { tg.offEvent('viewportChanged', updateViewport); }; }, []); // Handle Telegram native BackButton sync with Next.js router useEffect(() => { const tg = window.Telegram?.WebApp; if (!tg || !tg.BackButton) return; if (pathname !== '/') { tg.BackButton.show(); const handleBack = () => { router.back(); }; tg.BackButton.onClick(handleBack); return () => { tg.BackButton.offClick(handleBack); }; } else { tg.BackButton.hide(); } }, [pathname, router]); return null; } In your root layout (app/layout.tsx), apply the dynamic CSS variable to your master wrapper container to protect against layout clipping on iOS devices:
// app/layout.tsx import TelegramInitializer from '@/components/TelegramInitializer'; import Script from 'next/script'; import './globals.css'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <head> <Script src="https://telegram.org/js/telegram-web-app.js" strategy="beforeInteractive" /> </head> <body className="bg-[var(--tg-theme-bg-color,#fff)] text-[var(--tg-theme-text-color,#000)]" style={{ minHeight: 'var(--tg-viewport-height, 100vh)' }} Source:https://dev.to/serhii_a9c08345ac360cf5c8/prevent-spoofed-orders-in-nextjs-telegram-mini-apps-using-server-validated-mainbutton-checkout-3jfnRelated posts

Subresource Integrity (SRI): Pin Your CDN Scripts
Vibe-Coded App Security: A 30-Minute Pre-Launch Checklist
Why Drawing a Black Box on a PDF Doesn't Redact It (And How I Built a True Client-Side PDF Redactor with Canvas & WebAssembly)
