Building an e-commerce shop inside a Telegram Mini App using Next.js App Router offers a highly integrated user experience. However, developers often face critical integration hurdles. If you treat a Mini App like a standard web application, you will quickly encounter state desynchronization, broken viewports on iOS, and security vulnerabilities.
In this walkthrough, we will build a robust Next.js Mini App shop. We will address the state mismatch between React and Telegram's native MainButton, implement a secure Next.js Route Handler to validate initData and generate official Telegram invoices, and resolve common iOS and Desktop WebView layout bugs.
---
The Architecture of a Telegram Mini App Shop
A standard web application relies on an in-browser checkout flow. In Telegram, the optimal user experience uses the native MainButton (the persistent button at the bottom of the Telegram interface) to trigger checkouts, which then opens a native Telegram payment sheet.
Skipping proper integration leads to three major failure modes: 1. State Desynchronization: The Telegram MainButton is a global, imperative UI element controlled via the window.Telegram.WebApp SDK. If your React state changes (e.g., items are added or removed) but you do not clean up or update the MainButton click listeners, users will trigger checkouts with stale cart data or submit duplicate transactions. 2. The iOS Viewport Bug: On iOS, the Telegram WebView does not calculate 100vh correctly when the keyboard opens or when the MainButton is toggled. This causes elements to shift, rendering checkout buttons off-screen or hiding input fields. 3. Insecure Checkout: Generating invoices on the client-side exposes your Telegram Bot Token. We must route the checkout through a secure Next.js Route Handler that validates initData before calling the Bot API.
---
Setting Up the Next.js App Router and Telegram SDK Context
To interact with the Telegram WebApp SDK safely in a Next.js App Router environment, we must account for Server-Side Rendering (SSR). The window.Telegram object is only available on the client.
First, let's define our TypeScript interfaces and create a React Context Provider to manage the SDK state. This provider ensures the SDK is loaded and provides a safe wrapper around the WebApp object.
Create a file at src/context/TelegramProvider.tsx:
"use client";
import Script from "next/script";
import React, { createContext, useContext, useEffect, useState } from "react";
interface TelegramContextType {
webApp: WebApp | null;
isReady: boolean;
}
const TelegramContext = createContext<TelegramContextType>({
webApp: null,
isReady: false,
});
export const useTelegram = () => useContext(TelegramContext);
export function TelegramProvider({ children }: { children: React.ReactNode }) {
const [webApp, setWebApp] = useState<WebApp | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (typeof window !== "undefined" && window.Telegram?.WebApp) {
const tg = window.Telegram.WebApp;
tg.ready();
setWebApp(tg);
setIsReady(true);
}
}, []);
return (
<TelegramContext.Provider value={{ webApp, isReady }}>
<Script
src="https://telegram.org/js/telegram-web-app.js"
strategy="beforeInteractive"
onLoad={() => {
if (window.Telegram?.WebApp) {
const tg = window.Telegram.WebApp;
tg.ready();
setWebApp(tg);
setIsReady(true);
}
}}
/>
{children}
</TelegramContext.Provider>
);
}
Make sure to declare the global types in a global.d.ts file in your project root so TypeScript recognizes the Telegram SDK:
interface WebApp {
ready(): void;
initData: string;
initDataUnsafe: Record<string, any>;
MainButton: {
text: string;
color: string;
textColor: string;
isVisible: boolean;
isActive: boolean;
show(): void;
hide(): void;
enable(): void;
disable(): void;
showProgress(leaveActive: boolean): void;
hideProgress(): void;
onClick(callback: () => void): void;
offClick(callback: () => void): void;
setParams(params: Record<string, any>): void;
};
BackButton: {
isVisible: boolean;
show(): void;
hide(): void;
onClick(callback: () => void): void;
offClick(callback: () => void): void;
};
openInvoice(url: string, callback?: (status: string) => void): void;
themeParams: {
bg_color?: string;
text_color?: string;
button_color?: string;
button_text_color?: string;
};
}
interface Window {
Telegram?: {
WebApp: WebApp;
};
}
---
Synchronizing React Cart State with the Native MainButton
The most common bug in Mini App shops is the "stale click listener" on the MainButton. Because the MainButton is a singleton outside the React component tree, attaching a click listener inside a component that re-renders will either bind multiple instances of the listener or capture stale state variables in a closure.
To prevent this, we must use a dedicated useEffect hook that synchronizes the cart state with the MainButton visibility, text, and active state, and cleanly removes the event listener on cleanup.
Here is our ProductCatalog.tsx component:
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useTelegram } from "../context/TelegramProvider";
interface Product {
id: string;
name: string;
price: number;
}
const PRODUCTS: Product[] = [
{ id: "prod_1", name: "Premium Sticker Pack", price: 4.99 },
{ id: "prod_2", name: "Custom Bot Template", price: 19.99 },
{ id: "prod_3", name: "Developer Consultation", price: 49.99 },
];
export default function ProductCatalog() {
const { webApp, isReady } = useTelegram();
const [cart, setCart] = useState<Product[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const addToCart = (product: Product) => {
setCart((prev) => [...prev, product]);
};
const removeFromCart = (productId: string) => {
setCart((prev) => prev.filter((item) => item.id !== productId));
};
const totalAmount = cart.reduce((sum, item) => sum + item.price, 0);
const handleCheckout = useCallback(async () => {
if (!webApp || cart.length === 0 || isProcessing) return;
setIsProcessing(true);
webApp.MainButton.showProgress(false);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: webApp.initData,
cart: cart.map((item) => ({ id: item.id, name: item.name, price: item.price })),
}),
});
const data = await response.json();
if (!response.ok || !data.invoiceLink) {
throw new Error(data.error || "Failed to generate invoice link");
}
webApp.openInvoice(data.invoiceLink, (status) => {
webApp.MainButton.hideProgress();
setIsProcessing(false);
if (status === "paid") {
setCart([]);
alert("Payment successful! Thank you for your purchase.");
} else if (status === "failed") {
alert("Payment failed. Please try again.");
}
});
} catch (error: any) {
console.error("Checkout error:", error);
alert(error.message || "An error occurred during checkout.");
webApp.MainButton.hideProgress();
setIsProcessing(false);
}
}, [webApp, cart, isProcessing]);
useEffect(() => {
if (!isReady || !webApp) return;
const mainButton = webApp.MainButton;
if (cart.length > 0) {
mainButton.setParams({
text: `Pay $${totalAmount.toFixed(2)}`,
color: webApp.themeParams.button_color || "#2481cc",
text_color: webApp.themeParams.button_text_color || "#ffffff",
is_visible: true,
is_active: !isProcessing,
});
mainButton.onClick(handleCheckout);
} else {
mainButton.hide();
}
return () => {
mainButton.offClick(handleCheckout);
};
}, [cart, totalAmount, isReady, webApp, isProcessing, handleCheckout]);
return (
<div className="p-4 max-w-md mx-auto space-y-6">
<h1 className="text-2xl font-bold">Mini App Shop</h1>
<div className="space-y-4">
{PRODUCTS.map((product) => (
<div key={product.id} className="flex justify-between items-center p-4 border rounded-lg bg-card">
<div>
<h3 className="font-semibold">{product.name}</h3>
<p className="text-sm text-muted-foreground">${product.price.toFixed(2)}</p>
</div>
<button
onClick={() => addToCart(product)}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm font-medium"
>
Add
</button>
</div>
))}
</div>
{cart.length > 0 && (
<div className="mt-6 p-4 border rounded-lg bg-muted">
<h2 className="font-bold mb-2">Your Cart</h2>
{cart.map((item, index) => (
<div key={index} className="flex justify-between text-sm py-1">
<span>{item.name}</span>
<button onClick={() => removeFromCart(item.id)} className="text-destructive font-semibold">
Remove
</button>
</div>
))}
</div>
)}
</div>
);
}
---
Secure Checkout Route Handler (Validating initData and Issuing Invoices)
Never trust initDataUnsafe sent from the client. Anyone can open your Next.js app in a standard browser, mock the initDataUnsafe object, and trigger unauthorized actions.
To secure your backend, you must validate the raw initData query string using an HMAC-SHA256 signature check. The signature is generated by Telegram using your Bot Token as the secret key.
Once validated, the Next.js Route Handler calls the Telegram Bot API's createInvoiceLink method. This returns a secure payment link that the client can open natively.
Create a file at src/app/api/checkout/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto";
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const PROVIDER_TOKEN = process.env.PAYMENT_PROVIDER_TOKEN || ""; // Empty for Telegram Stars
function verifyInitData(rawInitData: string): boolean {
if (!BOT_TOKEN) {
console.error("TELEGRAM_BOT_TOKEN is not set in environment variables.");
return false;
}
const params = new URLSearchParams(rawInitData);
const hash = params.get("hash");
if (!hash) return false;
// Filter out hash and sort remaining parameters alphabetically
const keys = Array.from(params.keys()).filter((key) => key !== "hash");
keys.sort();
const dataCheckString = keys
.map((key) => `${key}=${params.get(key)}`)
.join("\n");
// Calculate secret key
const secretKey = createHmac("sha256", "WebAppData")
.update(BOT_TOKEN)
.digest();
// Calculate hash validation
const calculatedHash = createHmac("sha256", secretKey)
.update(dataCheckString)
.digest("hex");
// Verify auth_date is within a reasonable window (e.g., 24 hours) to prevent replay attacks
const authDate = parseInt(params.get("auth_date") || "0", 10);
const now = Math.floor(Date.now() / 1000);
if (now - authDate > 86400) {
return false;
}
return calculatedHash === hash;
}
export async function POST(req: NextRequest) {
try {
const { initData, cart } = await req.json();
if (!initData || !verifyInitData(initData)) {
return NextResponse.json({ error: "Unauthorized or invalid session" }, { status: 401 });
}
if (!cart || cart.length === 0) {
return NextResponse.json({ error: "Cart is empty" }, { status: 400 });
}
const totalAmount = Math.round(
cart.reduce((sum: number, item: any) => sum + item.price, 0) * 100
); // Amount in cents
// Generate a unique payload for tracking this order
const orderPayload = JSON.stringify({
orderId: `order_${binToHex(randomBytes(7))}`,
items: cart.map((i: any) => i.id),
});
const invoiceData = {
title: "Mini App Purchase",
description: `Order containing ${cart.length} item(s)`,
payload: orderPayload,
provider_token: PROVIDER_TOKEN,
currency: "USD",
prices: [
{ label: "Total Amount", amount: totalAmount }
],
};
const response = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/createInvoiceLink`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(invoiceData),
});
const result = await response.json();
if (!response.ok || !result.ok) {
console.error("Telegram Bot API Error:", result);
return NextResponse.json({ error: "Failed to generate invoice from Telegram" }, { status: 502 });
}
return NextResponse.json({ invoiceLink: result.result });
} catch (error) {
console.error("Checkout API Error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
function binToHex(buffer: Uint8Array): string {
return Array.from(buffer)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function randomBytes(length: number): Uint8Array {
const arr = new Uint8Array(length);
if (typeof window === "undefined") {
const { randomBytes } = require("crypto");
return new Uint8Array(randomBytes(length));
}
crypto.getRandomValues(arr);
return arr;
}
---
Resolving WebView Quirks (iOS, Desktop, and Theme Variables)
Telegram Mini Apps run inside WebViews that differ significantly between iOS, Android, and Desktop. If you do not style your application defensively, your layout will break.
### 1. iOS Viewport Height Bug Using 100vh in CSS causes layout shifts on iOS when the native MainButton is toggled or when the keyboard is active. Instead, use the CSS variables exposed by Telegram's script:
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--app-height: 100vh;
}
body {
background-color: var(--tg-theme-bg-color, #ffffff);
color: var(--tg-theme-text-color, #000000);
/* Use Telegram's stable viewport height variable if available */
height: var(--tg-viewport-stable-height, var(--app-height));
overflow-x: hidden;
overflow-y: auto;
}
### 2. Matching the Telegram Theme To make your Next.js application feel like a native extension of Telegram, map your UI colors to Telegram's theme variables. If you are using Tailwind, you can extend your theme configuration or use the variables directly in your CSS classes:
.bg-card {
background-color: var(--tg-theme-secondary-bg-color, #f4f4f5);
}
.text-muted-foreground {
color: var(--tg-theme-hint-color, #707579);
}
.bg-primary {
background-color: var(--tg-theme-button-color, #2481cc);
}
.text-primary-foreground {
color: var(--tg-theme-button-text-color, #ffffff);
}
### 3. Handling the Native Back Button When navigating between a product detail page and the catalog, use the native BackButton to maintain a clean UX. Here is how to integrate it with Next.js's router:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTelegram } from "../context/TelegramProvider";
export function useTelegramBackButton(shouldShow: boolean) {
const { webApp, isReady } = useTelegram();
const router = useRouter();
useEffect(() => {
if (!isReady || !webApp) return;
const backButton = webApp.BackButton;
if (shouldShow) {
backButton.show();
const handleBack = () => {
router.back();
};
backButton.onClick(handleBack);
return () => {
backButton.offClick(handleBack);
backButton.hide();
};
} else {
backButton.hide();
}
}, [isReady, webApp, shouldShow, router]);
}
---
Production Considerations
Before launching your Next.js shop to production, ensure you have implemented these operational safeguards:
* Idempotency and Order Tracking: The payload field in the invoice is passed back to your webhook when the payment is completed. Use a secure, cryptographically random order ID (e.g., generated via randomBytes) and store it in your database with a status of pending before sending the invoice link to the client. * Webhook Processing: To mark orders as paid, you must set up a separate Telegram Bot API webhook to listen for pre_checkout_query (which you must answer with ok: true within 10 seconds) and successful_payment updates. Do not fulfill orders solely based on the client-side openInvoice callback. * Initialization Delay: Only call Telegram.WebApp.ready() once your React application has completed mounting. Calling it too early can cause a flash of unstyled content or a blank screen on slower mobile connections.
If you need assistance designing, securing, or scaling your custom Telegram integrations, reach out to BotCreator — studio that ships Telegram bots / Mini Apps. For more details on handling payment webhooks, consult the official documentation at