Opening: you want to sell goods inside a Telegram Mini App. If you trust the raw initDataUnsafe object that the WebApp sends, a malicious user can forge it and trick your backend into thinking they are a legitimate user, leading to free orders or replay attacks. The fix is to verify the HMAC hash on the server before you enable the MainButton or create an invoice. In this guide we build a complete shop – product list, cart, MainButton‑triggered checkout, and a native Telegram invoice – using Next.js App Router with TypeScript. We also highlight the differences you’ll see between desktop Telegram and the iOS WebView (viewport handling, safe area, theme colour, and back‑button behaviour) so the UI works everywhere.

Setting up the Next.js App Router project

Start with a fresh Next.js app that uses the App Router (Next.js 13+). Install the official Telegram Mini Apps SDK which provides types and helpers for WebApp and the MainButton.

npx create-next-app@latest telegram-shop --ts cd telegram-shop npm i @telegram-apps/sdk 

Create a .env.local file (never commit it) with your bot token. The token is only needed by the backend route that creates invoices.

BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 

Next, add a small middleware that validates the initData header sent by the Mini App. We’ll put it in src/middleware.ts (the file lives at the project root).

// middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import crypto from 'crypto' // This runs on every request; we only protect API routes. export function middleware(req: NextRequest) { if (req.nextUrl.pathname.startsWith('/api/')) { const initData = req.headers.get('x-telegram-init-data') if (!initData) { return NextResponse.json({ error: 'missing initData' }, { status: 401 }) } // Parse the string into key/value pairs const params = new URLSearchParams(initData) const hash = params.get('hash') if (!hash) { return NextResponse.json({ error: 'missing hash' }, { status: 401 }) } // Remove hash from the data_check_string params.delete('hash') const dataCheckString = Array.from(params.entries()) .map(([k, v]) => `${k}=${v}`) .sort() .join('\n') const secret = crypto.createHmac('sha256', process.env.BOT_TOKEN!) .digest() const calculatedHash = crypto.createHmac('sha256', secret) .update(dataCheckString, 'utf8') .digest('hex') if (calculatedHash !== hash) { return NextResponse.json({ error: 'invalid hash' }, { status: 401 }) } // Optional: reject stale data (older than 10 minutes) const authDate = params.get('auth_date') if (authDate) { const age = Math.floor(Date.now() / 1000) - Number(authDate) if (age > 600) { return NextResponse.json({ error: 'stale initData' }, { status: 401 }) } } } return NextResponse.next() } 

The middleware checks the HMAC, ensures the request is recent, and blocks any tampered initData. With this in place, any API route under /api/ can safely treat the user as authenticated.

Building the product list and cart

We’ll keep product data in a simple file; in a real shop you’d fetch from a CMS or database.

// lib/products.ts export type Product = { id: string title: string description: string price: number // in smallest currency unit (e.g. cents) imageUrl: string } export const PRODUCTS: Product[] = [ { id: 'p1', title: 'Ceramic Mug', description: 'White 300 ml mug, dishwasher safe', price: 1299, imageUrl: 'https://example.com/mug.jpg' }, { id: 'p2', title: 'Sticker Pack', description: 'Five vinyl stickers, weatherproof', price: 499, imageUrl: 'https://example.com/stickers.jpg' }, ] 

Now create a cart provider that lives above the layout. It stores items as { productId: string, quantity: number } and exposes add/remove helpers.

// app/cart-provider.tsx 'use client' import { createContext, useContext, useState, ReactNode } from 'react' type CartItem = { productId: string; quantity: number } type CartContextType = { items: CartItem[] add: (productId: string) => void remove: (productId: string) => void total: number } const CartContext = createContext<CartContextType | null>(null) export function CartProvider({ children }: { children: ReactNode }) { const [items, setItems] = useState<CartItem[]>([]) const add = (productId: string) => { setItems(prev => { const existing = prev.find(i => i.productId === productId) if (existing) { return prev.map(i => i.productId === productId ? { ...i, quantity: i.quantity + 1 } : i ) } return [...prev, { productId, quantity: 1 }] }) } const remove = (productId: string) => { setItems(prev => { const existing = prev.find(i => i.productId === productId) if (!existing) return prev if (existing.quantity === 1) { return prev.filter(i => i.productId !== productId) } return prev.map(i => i.productId === productId ? { ...i, quantity: i.quantity - 1 } : i ) }) } const total = items.reduce((sum, it) => { const prod = PRODUCTS.find(p => p.id === it.productId) return sum + (prod ? prod.price * it.quantity : 0) }, 0) return ( <CartContext.Provider value={{ items, add, remove, total }}> {children} </CartContext.Provider> ) } export function useCart() { const ctx = useContext(CartContext) if (!ctx) throw new Error('useCart must be used within CartProvider') return ctx } 

Wrap the layout with this provider.

// app/layout.tsx import './globals.css' import type { Metadata } from 'next' import { CartProvider } from './cart-provider' export const metadata: Metadata = { title: 'Telegram Shop', description: 'Buy goods directly inside Telegram', } export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <CartProvider>{children}</CartProvider> </body> </html> ) } 

The product list page reads PRODUCTS and renders a grid. Each card has an “Add to cart” button that calls the cart context.

// app/page.tsx import { PRODUCTS, type Product } from '@/lib/products' import { useCart } from '@/cart-provider' export default function Home() { const { add } = useCart() return ( <main style={{ padding: '2rem' }}> <h1>Shop</h1> <div style={{ display: 'grid', gap: '1.5rem', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))' }}> {PRODUCTS.map((p: Product) => ( <article key={p.id} style={{ border: '1px solid #eee', borderRadius: '8px', overflow: 'hidden', background: '#fff' }} > <img src={p.imageUrl} alt={p.title} style={{ width: '100%', height: 'auto' }} /> <div style={{ padding: '1rem' }}> <h2 style={{ margin: '0 0 0.5rem' }}>{p.title}</h2> <p style={{ margin: '0 0 1rem', fontSize: '0.9rem', color: '#555' }}>{p.description}</p> <button onClick={() => add(p.id)} style={ { background: '#0088cc', color: '#fff', border: 'none', padding: '0.5rem 1rem', borderRadius: '4px', cursor: 'pointer', } } > Add to cart ({(p.price / 100).toFixed(2)} €) </button> </div> </article> ))} </div> </main> ) } 

Adding MainButton and handling checkout

The Mini App SDK provides a MainButton component that renders the native button at the bottom of the WebApp. We must wait until WebApp.ready is true before interacting with it, otherwise the button may be unresponsive.

Create a small reusable component that shows the button when the cart is not empty and disables it while the invoice is being created.

// components/MainButtonCheckout.tsx 'use client' import { useEffect, useState } from 'react' import { WebApp, MainButton } from '@telegram-apps/sdk' import { useCart } from '@/cart-provider' export default function MainButtonCheckout() { const { items, total } = useCart() const [loading, setLoading] = useState(false) useEffect(() => { if (!WebApp.isInitialized) { // Wait for the WebApp to be ready WebApp.ready() } // Show the button only when there is something to buy if (items.length > 0) { MainButton.setText(`Pay €{(total / 100).toFixed(2)}`) MainButton.show() } else { MainButton.hide() } // Handle click const onClick = async () => { if (loading || items.length === 0) return setLoading(true) MainButton.setText('Creating invoice…') MainButton.disable() try { const res = await fetch('/api/create-invoice', { method: 'POST', headers: { 'Content-Type': 'application/json', // send the validated initData so the API can identify the user 'x-telegram-init-data': window.WebApp.initData, }, body: JSON.stringify({ items }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const { invoice_url } = await res.json() // Open the Telegram invoice modal WebApp.openInvoice(invoice_url) } catch (err) { console.error(err) alert('Failed to create invoice') } finally { setLoading(false) MainButton.setText(`Pay €{(total / 100).toFixed(2)}`) MainButton.enable() } } MainButton.onClick(onClick) return () => MainButton.offClick(onClick) }, [items, total, loading]) return null // the component does not render anything; the SDK draws the button } 

Add this component to the layout so it appears on every page.

// app/layout.tsx (updated) import './globals.css' import type { Metadata } from 'next' import { CartProvider } from './cart-provider' import MainButtonCheckout from '@/components/MainButtonCheckout' export const metadata: Metadata = { title: 'Telegram Shop', description: 'Buy goods directly inside Telegram', } export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <CartProvider> {children} <MainButtonCheckout /> </CartProvider> </body> </html> ) } 

Creating Telegram invoice via bot API

When the MainButton is clicked we call a Next.js API route. This route validates the initData (again, via middleware), builds the invoice payload, and asks the Bot API for a payment link.

// app/api/create-invoice/route.ts import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import https from 'https' // Helper to POST to the Bot API function telegramPost(method: string, payload: any): Promise<any> { const data = JSON.stringify(payload) return new Promise((resolve, reject) => { const req = https....
Source:https://dev.to/serhii_a9c08345ac360cf5c8/telegram-mini-app-shop-in-nextjs-fixing-mainbutton-checkout-and-ios-viewport-4llo