Локально всё работает. Запускаете сборку — и она падает:
ReferenceError: window is not defined
Разработчик добавляет "use client" в начало файла. Пересобирает. Падает точно так же. Добавляет ещё в пару файлов выше по дереву. Не помогает.
В этом месте обычно и происходит недоразумение, из-за которого можно потерять полдня. "use client" не означает «не выполнять на сервере». Он означает совсем другое, и пока это не уложилось, чинить бесполезно.
Разберём, что на самом деле происходит, и три рабочих способа развести Telegram SDK с серверным рендерингом. Всё проверено на @telegram-apps/sdk-react 3.3.9 и Next.js 16.
Главное недоразумение: «use client» не отключает сервер
Название директивы сбивает с толку. "use client" не выводит компонент из серверного рендеринга, а помечает границу, за которой код попадает в клиентский бандл и получает состояние, эффекты и обработчики.
Но клиентские компоненты всё равно предварительно отрисовываются на сервере. Next.js собирает по ним HTML, отдаёт его браузеру, а уже потом React оживляет разметку на клиенте. Это и есть гидрация.
Отсюда следствие, которое ломает Mini App: код клиентского компонента выполняется дважды: один раз в Node.js, где нет ни window, ни клиента Telegram, и второй раз в браузере. Первый прогон и роняет сборку.
Для сайтов со статическим экспортом, как этот проект (output: "export"), ничего не меняется: сервера в рантайме нет, но пререндер во время сборки есть, и падает именно он.
Что именно падает
Три вещи, по убыванию частоты.
Инициализация SDK. Вызов init() обращается к окружению, которого на сервере не существует.
Чтение параметров запуска. retrieveLaunchParams() бросает LaunchParamsRetrieveError, когда брать параметры неоткуда.
Прямые обращения к глобальным объектам. Любое window.Telegram или document на верхнем уровне модуля выполняется в момент импорта — до того, как отработает любая ваша проверка.
Последний пункт коварнее прочих. Проверка внутри компонента не спасает, если обращение стоит в теле модуля:
// ❌ выполнится при импорте, на сервере тоже
const tg = window.Telegram.WebApp;
export function useTelegram() {
return tg;
}
Способ 1. Тонкая клиентская обёртка с отключённым SSR
Самый прямой путь: сказать Next.js не рендерить кусок дерева на сервере вообще.
Здесь есть подвох, на который натыкаются все. Опция ssr: false у next/dynamic не работает в серверных компонентах — App Router вернёт ошибку. Значит, нужна тонкая клиентская прослойка, единственная задача которой в том, чтобы объявить этот ssr: false:
// app/mini-app-shell.tsx
"use client";
import dynamic from "next/dynamic";
const MiniApp = dynamic(() => import("./mini-app"), {
ssr: false,
loading: () => <div>Загружаем…</div>,
});
export default function MiniAppShell() {
return <MiniApp />;
}
Страница при этом остаётся серверным компонентом и подключает только обёртку:
// app/page.tsx
import MiniAppShell from "./mini-app-shell";
export default function Page() {
return <MiniAppShell />;
}
Всё, что внутри ./mini-app, на сервер больше не попадает. Способ грубый, зато надёжный, и для приложения, которое всё равно живёт только внутри Telegram, он чаще всего и нужен.
Цена такого решения в отсутствии серверной разметки: пользователь увидит заглушку из loading, пока не приедет JavaScript.
Способ 2. Инициализация в эффекте
Если выносить всё приложение не хочется, можно оставить дерево на месте, а инициализацию перенести в момент, который на сервере не наступает.
Эффекты выполняются только в браузере, и этого достаточно:
"use client";
import { useEffect, useState } from "react";
import { init, isTMA, miniAppReady } from "@telegram-apps/sdk-react";
export function TelegramProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
if (!isTMA()) return; // открыли не из Telegram
const cleanup = init();
miniAppReady();
setReady(true);
return cleanup; // init возвращает функцию очистки
}, []);
if (!ready) return <div>Загружаем…</div>;
return <>{children}</>;
}
Два момента, которые часто упускают. init() возвращает функцию очистки, и её нужно вернуть из эффекта, иначе при повторном монтировании в строгом режиме обработчики накопятся. И проверка isTMA() обязана стоять до init(), иначе приложение упадёт при открытии обычной ссылкой в браузере.
Способ 3. Честный SSR через серверный снимок
Первые два способа жертвуют серверной разметкой. Если она нужна, например у Mini App есть публичная витрина, которую хочется отдавать сразу, то SDK умеет работать корректно.
Хук useSignal принимает вторым аргументом функцию, возвращающую значение для сервера:
"use client";
import { useSignal, backButton } from "@telegram-apps/sdk-react";
export function BackButtonHint() {
// На сервере вернётся false, на клиенте реальное состояние сигнала
const isVisible = useSignal(backButton.isVisible, () => false);
return (
<span>
{isVisible ? "Кнопка назад активна" : "Навигация внутри приложения"}
</span>
);
}
Смысл второго аргумента именно в гидрации. React сверяет разметку, собранную на сервере, с тем, что получилось в браузере. Если сервер отрисовал одно, а клиент сразу же другое, вы получите предупреждение о расхождении, а в неудачных случаях React выбросит серверную разметку и перерисует поддерево целиком.
Серверный снимок делает первый клиентский рендер идентичным серверному. Реальное значение приезжает следующим рендером, уже после гидрации, и расхождения не возникает.
Для условий вне сигналов у пакета есть флаг isSSR:
import { isSSR } from "@telegram-apps/sdk-react";
if (!isSSR) {
// безопасно трогать браузерное окружение
}
Ловушка, о которой узнают на проде: iframe
Этот пункт не про сборку и потому всплывает позже всех, уже после успешного деплоя. На телефоне приложение работает, а в веб-версии Telegram белый экран.
Причина в том, что веб-клиент Telegram открывает Mini App внутри iframe. Многие сборки Next.js по умолчанию или через конфигурацию отдают заголовок X-Frame-Options: DENY, а некоторые хостинги добавляют его сами. Браузер честно отказывается встраивать страницу, и пользователь видит пустоту.
Проверяется одной командой:
curl -sI https://ваш-домен.ru | grep -i "x-frame-options\|content-security-policy"
Если X-Frame-Options присутствует, его нужно убрать, а ограничение выразить через frame-ancestors в Content-Security-Policy, разрешив домены Telegram. Заголовок X-Frame-Options не умеет списка источников, поэтому заменить его на что-то избирательное нельзя, только снять.
Гидрация: три источника расхождений
Помимо сигналов SDK, разметка расходится ещё по трём причинам, и все три встречаются в Mini App постоянно.
Тема пользователя. Цвета приезжают из клиента Telegram, на сервере их нет. Не подставляйте их в разметку напрямую — привяжите к CSS-переменным через bindThemeParamsCssVars(), тогда сервер отдаёт нейтральный HTML, а цвета применяются стилями.
Размеры экрана. Высота из viewport на сервере неизвестна. Тот же приём: bindViewportCssVars() и вёрстка от переменных вместо 100vh.
Данные пользователя. Имя и аватар из initData на сервере недоступны. Отрисуйте на первом рендере плейсхолдер — тот же, что вернёт сервер.
Общее правило: всё, что известно только внутри Telegram, не должно влиять на первый рендер.
Чек-лист перед деплоем
Заключение
Почти все проблемы Mini App на Next.js сводятся к одной мысли: фреймворк по умолчанию считает, что страницу можно отрисовать без браузера, а Telegram SDK устроен ровно наоборот — он не существует вне клиента Telegram.
Дальше остаётся выбрать, где провести границу. Отключить серверный рендеринг целиком проще всего, и это подходит большинству приложений. Оставить его и аккуратно подсунуть серверные снимки дороже, но окупается, если у приложения есть публичная часть.
Ошибкой будет только третий вариант: оставить всё как есть и расставлять проверки на window по мере падений.
Читайте также