Running a интернет-магазин inside a Telegram Mini App (TMA) using the Next.js App Router stack allows you to combine the performance of server-side rendering with a native Telegram client experience. However, when implementing E-commerce scenarios, developers regularly encounter specific issues: behavioral discrepancies between the iOS WebView and the Desktop client, proper management of native MainButton/BackButton controls, and processing native Telegram Invoices payment.
Architecture of an E-commerce Mini App on Next.js App Router
When building a store on Next.js App Router, it is important to separate server code from client interactive components. The full context environment window.Telegram.WebApp is available exclusively on the client (CSR). The window object is absent on the server (SSR/RSC), so DOM access errors must be excluded during initial hydration.
The standard checkout flow looks like this:
- The user adds items to the cart (state is stored in client React State or Zustand).
- When the cart changes, native
Telegram.WebApp.MainButtonis invoked, displaying the total amount. - A click on
MainButtonsends the cart data to the Next.js backend (Route Handler). - The Route Handler generates an invoice via the
createInvoiceLinkTelegram Bot API method and returns the payment URL. - The frontend calls
Telegram.WebApp.openInvoice, opening the native платежный шлюз.
Initializing WebApp SDK and Workarounds for iOS / Desktop WebView Bugs
The most common issues when designing Telegram Mini Apps are viewport collapse on iOS during swipes, incorrect screen height when the on-screen keyboard appears, and incorrect system colors on the PC version of Telegram Desktop.
To resolve these bugs, a custom initialization hook is created that expands the application window via expand(), locks the actual height viewportStableHeight in a CSS variable, and passes the native Telegram color palette into theme variables.
'use client';
import { useEffect } from 'react';
declare global {
interface Window {
Telegram?: {
WebApp: any;
};
}
}
export function useTelegramInit() {
useEffect(() => {
if (typeof window === 'undefined' || !window.Telegram?.WebApp) return;
const webApp = window.Telegram.WebApp;
// Сообщаем Telegram, что приложение готово к отображению
webApp.ready();
// Принудительно расширяем WebView на весь экран
webApp.expand();
// Фикс для iOS: отслеживаем изменения viewport и задаем CSS-переменную
const updateViewport = () => {
const stableHeight = webApp.viewportStableHeight || window.innerHeight;
document.documentElement.style.setProperty('--tg-viewport-height', `${stableHeight}px`);
};
webApp.onEvent('viewportChanged', updateViewport);
updateViewport();
// Синхронизация цветовой схемы с клиентом Telegram
const theme = webApp.themeParams;
if (theme) {
const root = document.documentElement;
if (theme.bg_color) root.style.setProperty('--tg-theme-bg', theme.bg_color);
if (theme.text_color) root.style.setProperty('--tg-theme-text', theme.text_color);
if (theme.button_color) root.style.setProperty('--tg-theme-button', theme.button_color);
if (theme.button_text_color) root.style.setProperty('--tg-theme-button-text', theme.button_text_color);
if (theme.secondary_bg_color) root.style.setProperty('--tg-theme-secondary-bg', theme.secondary_bg_color);
}
return () => {
webApp.offEvent('viewportChanged', updateViewport);
};
}, []);
}
Important for iOS: Viewport height in iOS Safari WebView can dynamically change on scroll. Be sure to use the calculated CSS value
height: var(--tg-viewport-height, 100vh)for the main application container instead of100vhto prevent elements from overflowing outside the visible area.
Synchronizing the Cart with MainButton and Processing the Order
To create a seamless mobile UX, оформление заказа should be bound to the bottom MainButton control. In Telegram Desktop, this button is rendered fixed at the bottom of the WebApp modal window.
When the cart contents change, the reactive component must update the button text, toggle its visibility, and attach a data submission handler.
'use client';
import { useEffect, useState, useCallback } from 'react';
export interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
}
interface CartProps {
items: CartItem[];
}
export function CheckoutCart({ items }: CartProps) {
const [loading, setLoading] = useState(false);
const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const handleCheckout = useCallback(async () => {
if (items.length === 0 || loading) return;
const webApp = window.Telegram?.WebApp;
if (!webApp) return;
setLoading(true);
webApp.MainButton.showProgress(false);
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items,
initData: webApp.initData,
}),
});
const data = await response.json();
if (data.ok && data.invoiceLink) {
// Открываем нативную оплату Telegram
webApp.openInvoice(data.invoiceLink, (status: string) => {
webApp.MainButton.hideProgress();
setLoading(false);
if (status === 'paid') {
webApp.showAlert('Оплата прошла успешно!');
webApp.close();
} else if (status === 'failed') {
webApp.showAlert('Не удалось завершить платеж.');
}
});
} else {
webApp.showAlert(data.error || 'Ошибка при создании счета');
webApp.MainButton.hideProgress();
setLoading(false);
}
} catch (err) {
console.error('Checkout error:', err);
webApp.showAlert('Сетевая ошибка. Попробуйте снова.');
webApp.MainButton.hideProgress();
setLoading(false);
}
}, [items, loading]);
useEffect(() => {
if (typeof window === 'undefined' || !window.Telegram?.WebApp) return;
const webApp = window.Telegram.WebApp;
const mainButton = webApp.MainButton;
if (items.length > 0) {
mainButton.setText(`ОПЛАТИТЬ (${totalPrice} ₽)`);
mainButton.show();
mainButton.enable();
mainButton.onClick(handleCheckout);
} else {
mainButton.hide();
}
return () => {
mainButton.offClick(handleCheckout);
};
}, [items, totalPrice, handleCheckout]);
return (
<div className="p-4 rounded-lg bg-[var(--tg-theme-secondary-bg,#f4f4f5)]">
<h3 className="font-semibold text-lg mb-2">Ваш заказ</h3>
{items.length === 0 ? (
<p className="text-sm opacity-70">Корзина пуста</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li key={item.id} className="flex justify-between text-sm">
<span>{item.title} x {item.quantity}</span>
<span className="font-medium">{item.price * item.quantity} ₽</span>
</li>
))}
</ul>
)}
</div>
);
}
Generating a Payment Link (createInvoiceLink) in a Route Handler
For secure payment processing, the payment provider token (such as YooKassa, Sberbank, or Telegram Stars) and the bot token must never leak to the client side. Generating the Генерация ссылки for payment takes place strictly on the server via the Telegram Bot API method createInvoiceLink.
Creating the Route Handler app/api/checkout/route.ts in Next.js App Router:
import { NextResponse } from 'next/server';
interface ReqItem {
id: string;
title: string;
price: number;
quantity: number;
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { items, initData } = body;
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ ok: false, error: 'Корзина пуста' }, { status: 400 });
}
const botToken = process.env.BOT_TOKEN;
const providerToken = process.env.PAYMENT_PROVIDER_TOKEN;
if (!botToken || !providerToken) {
return NextResponse.json(
{ ok: false, error: 'Конфигурация платежей не настроена на сервере' },
{ status: 500 }
);
}
// Формируем позиции чека. Для RUB суммы указываются в копейках (целые числа)
const prices = items.map((item: ReqItem) => ({
label: `${item.title} x${item.quantity}`,
amount: Math.round(item.price * item.quantity * 100),
}));
const totalAmount = items.reduce((acc, i) => acc + i.price * i.quantity, 0);
const payload = {
title: 'Оплата заказа в Mini App',
description: `Заказ из товаров в количестве: ${items.length} шт.`,
payload: JSON.stringify({
order_id: `ORD-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
createdAt: Date.now(),
}),
provider_token: providerToken,
currency: 'RUB',
prices: prices,
};
const tgResponse = await fetch(`https://api.telegram.org/bot${botToken}/createInvoiceLink`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const tgData = await tgResponse.json();
if (!tgResponse.ok || !tgData.ok) {
console.error('Telegram createInvoiceLink error:', tgData);
return NextResponse.json(
{ ok: false, error: tgData.description || 'Ошибка Telegram API' },
{ status: 502 }
);
}
return NextResponse.json({
ok: true,
invoiceLink: tgData.result,
});
} catch (err) {
console.error('Checkout API Route error:', err);
return NextResponse.json({ ok: false, error: 'Внутренняя ошибка сервера' }, { status: 500 });
}
}
Behavioral Differences and Bugs: iOS WebView vs Telegram Desktop
1. Handling the Native BackButton Control During Navigation
In Telegram Desktop, when clicking BackButton, the client expects the button to be explicitly hidden via webApp.BackButton.hide() upon navigating to the application home screen. Otherwise, the "Back" button will remain pinned in the window header even on the main store page. On iOS, the button disappears automatically only when closing the app. Control button visibility using usePathname() from next/navigation.
2. Theme Switching and Transparent Background Behavior
When switching color themes (Dark/Light) in the Desktop client, CSS color variables update instantly, but the window background itself may stay white until a repaint is triggered. To prevent white flashes on iOS and Desktop, explicitly define styles for the html and body tags:
body {
background-color: var(--tg-theme-bg, #ffffff);
color: var(--tg-theme-text, #000000);
min-height: var(--tg-viewport-height, 100vh);
overflow-x: hidden;
}
3. Closing the WebView After Successful Payment
On iOS, calling webApp.close() immediately after closing the native openInvoice payment gateway can sometimes cause the interface to freeze if the payment modal animation has not fully completed. It is recommended to set a minimal timeout of 150–200 ms before invoking the close() method, or provide user feedback via a thank-you screen with a separate close button.
Need help with professional development of Telegram Mini Apps and sales automation of any complexity? Contact the BotCreator web studio.