Разработка магазина в Telegram Mini App на Next.js App Router: корзина, MainButton и баги WebView

Запуск интернет-магазина внутри Telegram Mini App (TMA) на стекe Next.js App Router позволяет объединить быстродействие серверного рендеринга и нативный клиентский опыт Telegram. Однако при реализации E-commerce сценариев разработчики регулярно сталкиваются со специфическими проблемами: расхождениями в поведении iOS WebView и Desktop-клиента, корректным управлением нативными кнопками MainButton/BackButton и обработкой нативной оплаты Telegram Invoices.

Архитектура E-commerce Mini App на Next.js App Router

При построении магазина на Next.js App Router важно разграничивать серверный код и клиентские интерактивные компоненты. Полноценное контекстное окружение window.Telegram.WebApp доступно исключительно на клиенте (CSR). На сервере (SSR/RSC) объект window отсутствует, поэтому при первой гидратации необходимо исключить ошибки доступа к DOM.

Стандартный поток оформления заказа выглядит так:

  1. Пользователь собирает товары в корзину (состояние хранится в клиентском React State или Zustand).
  2. При изменении корзины вызывается нативный Telegram.WebApp.MainButton, отображающий итоговую сумму.
  3. Клик по MainButton отправляет данные корзины на бэкенд Next.js (Route Handler).
  4. Route Handler формирует инвойс через метод createInvoiceLink Telegram Bot API и возвращает URL оплаты.
  5. Фронтенд вызывает Telegram.WebApp.openInvoice, открывая нативный платежный шлюз.

Инициализация WebApp SDK и обход багов iOS / Desktop WebView

Наиболее частые проблемы при верстке Mini App под Telegram — это схлопывание рабочей области на iOS при свайпе, некорректная высота экрана при появлении экранной клавиатуры и некорректные системные цвета на ПК-версии Telegram Desktop.

Для решения этих багов создается кастомный хук инициализации, который раскрывает окно приложения через expand(), фиксирует реальную высоту viewportStableHeight в CSS-переменной и прокидывает системную палитру Telegram в переменные темы.

'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);
};
}, []);
}

Важно для iOS: Высота Viewport в iOS Safari WebView может динамически меняться при скролле. Обязательно используйте в CSS вычисленное значение height: var(--tg-viewport-height, 100vh) для главного контейнера приложения вместо 100vh, чтобы избежать вылета элементов за пределы видимости.

Синхронизация корзины с MainButton и обработка заказа

Для создания бесшовного UX в мобильных устройствах оформление заказа следует привязывать к нижней кнопке MainButton. В Telegram Desktop эта кнопка отображается зафиксированной внизу модального окна WebApp.

При изменении состава корзины реактивный компонент должен изменять текст кнопки, переключать ее видимость и навешивать обработчик отправки данных.

'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>
);
}

Генерация ссылки на оплату (createInvoiceLink) в Route Handler

Для безопасного проведения платежей токен платежного провайдера (например, ЮKassa, Сбербанк или Telegram Stars) и бот-токен не должны попадать на клиентскую сторону. Генерация ссылки на оплату происходит исключительно на сервере через Telegram Bot API метод createInvoiceLink.

Создаем Route Handler app/api/checkout/route.ts в 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 });
}
}

Различия в поведении и баги: iOS WebView vs Telegram Desktop

1. Обработка нативной кнопки BackButton при навигации

В Telegram Desktop при клике по BackButton клиент ожидает явного скрытия кнопки через webApp.BackButton.hide() при переходе на главный экран приложения. В противном случае кнопка «Назад» останется висеть в шапке окна даже на главной странице магазина. На iOS кнопка пропадает автоматически только при закрытии приложения. Контролируйте видимость кнопки через usePathname() из next/navigation.

2. Смена темы и прозрачность прозрачных фонов (Transparent Background)

При переключении тем оформления (Dark/Light) в Desktop-клиенте CSS-переменные цвета обновляются мгновенно, но фон самого окна может оставаться белым до вызова перерисовки. Чтобы избежать белых вспышек на iOS и Desktop, явно задайте стили для тегов html и body:

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. Закрытие WebView после успешной оплаты

На iOS при вызове метода webApp.close() сразу после закрытия нативного шлюза оплаты openInvoice иногда происходит фриз интерфейса, если окно платежа еще не успело полностью завершить анимацию скрытия. Рекомендуется ставить минимальный таймаут в 150-200 мс перед вызовом метода close(), либо давать пользователю фидбек через экран благодарности с отдельной кнопкой закрытия.

Нужна помощь с профессиональной разработкой Telegram Mini Apps и автоматизацией продаж любой сложности? Обращайтесь в веб-студию BotCreator.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.