Telegram Mini App shop in Next.js fixing MainButton checkout and iOS viewport

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.request(
`https://api.telegram.org/bot${process.env.BOT_TOKEN}/${method}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' } },
res => {
let raw = ''
res.on('data', chunk => (raw += chunk))
res.on('end', () => {
try {
const parsed = JSON.parse(raw)
if (!parsed.ok) {
reject(new Error(parsed.description ?? 'Telegram error'))
} else {
resolve(parsed.result)
}
} catch (e) {
reject(e)
}
})
}
)
req.on('error', reject)
req.write(data)
req.end()
})
}

export async function POST(req: NextRequest) {
// Middleware already validated initData, but we still need the user ID
const initData = req.headers.get('x-telegram-init-data')
if (!initData) {
return NextResponse.json({ error: 'missing initData' }, { status: 401 })
}
const params = new URLSearchParams(initData)
const userId = params.get('user')
if (!userId) {
return NextResponse.json({ error: 'cannot extract user' }, { status: 400 })
}
const { items } = await req.json()
if (!Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: 'empty cart' }, { status: 400 })
}

// Build the price list expected by the Bot API
const priceItems = items.map(it => {
const prod = PRODUCTS.find(p => p.id === it.productId)
if (!prod) {
throw new Error(`Unknown product ${it.productId}`)
}
return {
label: prod.title,
amount: prod.price * it.quantity // amount in smallest currency unit
}
})

const payload = {
chat_id: Number(userId),
title: 'Your Telegram Shop order',
description: 'Purchase from the Mini App',
payload: Buffer.from(JSON.stringify({ items })).toString('base64'), // optional custom payload
provider_token: '', // empty for payments via Telegram Stars (if you enable Stars)
currency: 'EUR',
prices: priceItems,
need_name: false,
need_phone_number: false,
need_email: false,
need_shipping_address: false,
is_flexible: false,
}

try {
const result = await telegramPost('createInvoiceLink', payload)
return NextResponse.json({ invoice_url: result })
} catch (e: any) {
console.error(e)
return NextResponse.json({ error: e.message }, { status: 500 })
}
}

The route returns a URL that the Mini App opens with WebApp.openInvoice. Telegram shows the native payment form; after the user completes the payment you’ll receive a pre_checkout_query and then a successful_payment update in your bot. Handling those updates is outside the Mini App scope but you can set up a webhook for your bot to verify the payment and mark the order as fulfilled.

Desktop vs iOS WebView quirks

When you test the shop you’ll notice three common differences:

1. Viewport and safe area – On iOS the WebApp renders inside a WKWebView that respects the device’s safe area (the notch, home‑indicator, and rounded corners). If you rely on 100vh for a full‑height layout, the bottom part can be hidden under the indicator. Fix: use height: 100dvh (dynamic viewport height) which is supported in modern browsers, or fallback to height: -webkit-fill-available and add padding-bottom: env(safe-area-inset-bottom) via CSS.

2. Theme colour – Telegram lets you set the theme colour via the bg_color attribute in the WebApp init data. On desktop the WebApp follows the OS theme, while on iOS it overrides the background with the colour you provide. To avoid a flash of white, set a meta tag early in <head>:

   <meta name="theme-color" content="#0088cc" />

and keep your CSS background matching that colour.

3. Back‑button behaviour – In the desktop WebApp the browser’s back button navigates the Mini App’s history. In the iOS WebView the hardware back gesture is intercepted by Telegram and closes the WebApp unless you call WebApp.disableBackButton() when you want to keep the user inside the flow (e.g., while showing the invoice). After the invoice is dismissed, re‑enable it with WebApp.enableBackButton().

Add a small effect to handle this:

// app/layout.tsx (inside RootLayout, after MainButtonCheckout)
useEffect(() => {
if (WebApp.isInitialized) {
// Show the back button when we are on the product list or cart
WebApp.enableBackButton()
// Optional: handle the back event to stay inside the app
WebApp.onBackButton(() => {
// navigate back in Next.js router if you have a stack
// router.back()
})
}
}, [])

Production notes

- Expiry and replay – The middleware rejects initData older than ten minutes (auth_date). Store the hash you’ve seen in a short‑term cache (Redis) if you want to protect against replay attacks within that window. - MainButton timing – Never call MainButton.show() or set its text before WebApp.ready() resolves. The component above waits for the ready promise. - Idempotency – The invoice creation endpoint should be safe to call multiple times. Because we generate the invoice link from the cart contents, calling it twice with the same cart returns the same URL (Telegram’s API is idempotent for createInvoiceLink). If you later store orders in a database, make the order ID deterministic (e.g., hash of cart + user ID) and check for existing rows before inserting. - Error handling – Always check the HTTP status of the Bot API response and bubble a user‑friendly message. Never expose the raw initData to the client. - Testing – Use the @telegram-apps/sdk in a local dev server (next dev) and open the link t.me/yourbot?start=appdata to launch the Mini App. On iOS, open Telegram, go to the bot, and press “Open Web App” to see the WebView.

You now have a complete, type‑safe Next.js Mini App shop that validates initData, shows a MainButton‑driven checkout, creates a Telegram invoice, and works around the quirks of desktop and iOS WebViews. The same patterns apply to any product catalogue or service you want to sell inside Telegram.

BotCreator — studio that ships Telegram bots / Mini Apps.

Optional further reading:

New articles on Telegram

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