Устранение ошибки «window is not defined» в приложениях с серверным рендерингом
Исправьте window is not defined в серверно-рендеренных приложениях с помощью on-mount hooks, проверок typeof window и client-only imports.
Ошибка window is not defined означает, что ваш код выполнился в Node.js, где объекта window не существует: фреймворки с серверным рендерингом сначала выполняют ваши компоненты на сервере, ещё до того, как в дело вступает браузер.
Обычно эта ошибка возникает сразу после того, как вы добавили серверный рендеринг в работающее приложение или перенесли компонент, прекрасно работавший на клиенте, в Next, Nuxt, SvelteKit, Astro или React Router. Сам компонент не изменился. Изменилось место его выполнения — и стек вызовов подскажет, какое из трёх приведённых ниже решений вам нужно.
Ключевые выводы
window is not definedозначает, что код выполнился в Node.js, гдеwindowне существует ни на каком этапе ни одного жизненного цикла; это не проблема таймингов.- Решение по умолчанию — перенести обращение в хук монтирования (
useEffect,onMounted,onMount), поскольку хуки монтирования никогда не выполняются на сервере. - Проверка
typeof window !== 'undefined'уместна в коде на уровне модуля и в общих утилитах; внутри рендера компонента она приводит к расхождению серверного и клиентского HTML. - Рендеринг только на клиенте — крайняя мера: он полностью исключает компонент из HTML, формируемого на сервере.
- Тот же сбой может произойти на этапе сборки, поскольку статическая генерация выполняет компоненты в Node для получения HTML.
Почему в приложениях с серверным рендерингом возникает «window is not defined»?
Приложения с серверным рендерингом выполняют ваши компоненты дважды: сначала в Node.js для формирования HTML, затем повторно в браузере. Глобальная область видимости Node.js не содержит ни window, ни document, поэтому любой код, обращающийся к ним во время серверного прохода, выбрасывает ReferenceError. Объект не «пока недоступен» — в Node он не существует в принципе.
function ThemeBadge() {
// ReferenceError: window is not defined (thrown during the server render)
const theme = window.localStorage.getItem('theme');
return <span>{theme}</span>;
}
То же самое происходит и без какого-либо запроса. Статическая генерация выполняет ваши компоненты в Node во время сборки для получения HTML, поэтому обращение к window может привести к сбою во время next build или пререндеринга, и стек вызовов появится в выводе сборки, а не в серверном логе. SvelteKit даже предоставляет для этой фазы константу building, которая равна true во время пререндеринга. Таким образом, компонент, который в режиме разработки рендерится исключительно на клиенте, может успешно пройти локальное тестирование и всё равно сломать продакшен-сборку.
Что если сбой происходит в зависимости?
Если верхние кадры стека вызовов указывают внутрь node_modules, значит, какая-то зависимость читает window во время импорта, и ошибка выбрасывается ещё до того, как выполнится хоть строчка кода вашего компонента. Обычные подозреваемые — библиотеки для построения графиков, SDK для встраиваемых виджетов и всё, что обращается к DOM на уровне модуля.
ReferenceError: window is not defined
at node_modules/some-chart-lib/dist/index.js:12:3
at Module._compile (node:internal/modules/cjs/loader:1358:14)
Именно это различие определяет решение. Ошибка на этапе импорта возникает при загрузке модуля, поэтому оборачивание собственного кода в хук монтирования не поможет: сбой происходит до того, как компонент вообще появится. Для таких пакетов переходите сразу к импорту только на клиенте — решению номер три.
Решение 1: перенесите обращение в хук монтирования
Решение по умолчанию — перенести обращение к window в хук монтирования вашего фреймворка, поскольку хуки монтирования выполняются исключительно в браузере. Справочник React по useEffect прямо говорит об этом: серверный рендеринг пропускает эффекты, и они срабатывают только после того, как компонент попадает в браузер. Аналоги: Vue и Nuxt используют onMounted, Svelte и SvelteKit — onMount, который компонент, отрендеренный на сервере, никогда не вызывает, React Router использует useEffect из React, а в Astro браузерный код размещают в хуках жизненного цикла островка соответствующего фреймворка.
import { useState, useEffect } from 'react';
function ThemeBadge() {
const [theme, setTheme] = useState(null);
useEffect(() => {
setTheme(window.localStorage.getItem('theme')); // browser only
}, []);
return <span>{theme ?? 'default'}</span>;
}
Сервер рендерит состояние-заглушку, браузер монтирует компонент, эффект отрабатывает, и подставляется реальное значение. При этом серверный HTML для остальной части компонента остаётся нетронутым — именно поэтому такой подход предпочтительнее двух других в качестве варианта по умолчанию.
Решение 2: используйте проверку typeof window !== ‘undefined’
Проверка typeof window !== 'undefined' — подходящий инструмент для кода на уровне модуля и общих утилит, где хуки жизненного цикла недоступны.
// theme.js — a shared utility, no component lifecycle to lean on
export function getStoredTheme() {
if (typeof window === 'undefined') return 'light'; // server fallback
return window.localStorage.getItem('theme') ?? 'light';
}
SvelteKit предлагает более элегантный эквивалент — константу browser, и его FAQ по клиентским библиотекам рассматривает эту константу как стандартный способ отгородить всё, что обращается к document или window.
Однако внутри рендера компонента такая проверка подходит плохо: она приводит к тому, что сервер и браузер формируют разный HTML для одного и того же компонента, меняя сбой на рассогласование при передаче управления клиенту. Используйте проверку в обычных функциях и на уровне модуля; внутри компонентов применяйте решение номер один.
Решение 3: откажитесь от серверного рендеринга компонента
Крайняя мера — динамический импорт только на клиенте, который полностью исключает компонент из серверного рендеринга. В Next.js это делает next/dynamic с ssr: false внутри клиентского компонента (в серверных компонентах он выдаёт ошибку, поэтому добавьте тонкую обёртку с 'use client'). В Nuxt есть <ClientOnly>, а в Astro — директива client:only.
'use client';
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'), {
ssr: false,
loading: () => <div style={{ height: 320 }} aria-hidden="true" />,
});
Прежде чем прибегать к этому, осознайте цену: сервер не отдаёт HTML для этого поддерева, поэтому компонент отсутствует в исходном HTML, что может навредить SEO и отсрочить интерактивность. Приберегите этот вариант для компонентов, которые вы не можете изменить, — прежде всего для зависимостей, выбрасывающих ошибку на этапе импорта.
Избавьтесь от «выскакивания» с помощью плейсхолдера той же формы
Плейсхолдер предотвращает сдвиг макета только в том случае, если занимает те же размеры, что и компонент, который он замещает. Возврат null на сервере означает, что после запуска JavaScript компонент появляется из ниоткуда, сдвигая вниз всё, что расположено под ним. Скелетон с фиксированными размерами, как div высотой 320px выше, удерживает место до появления настоящей разметки. Решение о том, рендерить ли плейсхолдер или вообще null, — это тот же компромисс, что лежит в основе многих рассогласований гидратации; подробно он разобран в нашем руководстве по устранению ошибок гидратации в Next.js. Записи сессий с клиентскими заглушками делают подмену плейсхолдера контентом заметной в виде скачка макета — это самый быстрый способ проверить, действительно ли плейсхолдер соответствует разметке, которую он замещает.
Какое решение подходит в вашем случае?
- Ваш компонент читает
windowв собственном коде: перенесите обращение в хук монтирования. Выбор по умолчанию. - К
windowобращается общая утилита или инструкция на уровне модуля: добавьте проверкуtypeof windowс запасным значением для сервера. - Стек вызовов указывает внутрь
node_modulesна этапе импорта: динамический импорт только на клиенте с плейсхолдером той же формы. - Ошибка появляется только в выводе сборки: диагностика та же, что и выше; статическая генерация выполняет тот же самый код в Node.
Сначала прочитайте стек вызовов
Эта ошибка — проблема окружения, а не таймингов: какая-то строка кода выполнилась в Node, где window никогда не существовал. Сначала прочитайте стек вызовов. Если верхний кадр принадлежит вашему коду, проблему решит хук монтирования или проверка, сохранив при этом серверный HTML. Если он указывает внутрь node_modules, изолируйте зависимость за импортом только на клиенте и дайте ей плейсхолдер, удерживающий макет.
Часто задаваемые вопросы
Является ли «document is not defined» той же проблемой, что и «window is not defined»?
Да. У обеих ошибок одна и та же причина: код выполнился в Node.js, в глобальной области видимости которого нет ни window, ни document. Применимы та же диагностика и те же три решения: перенесите обращение в хук монтирования, защитите код на уровне модуля проверкой typeof или рендерите компонент только на клиенте, если зависимость обращается к DOM на этапе импорта.
Можно ли исправить ошибку, определив глобальный объект window на сервере?
Не стоит. Присвоение поддельного window объекту globalThis подавляет ReferenceError, но тогда сервер рендерит разметку на основе фиктивных значений, а всё, что хранится в этом полифилле, разделяется между всеми запросами, которые обрабатывает сервер. Кроме того, это скрывает сбои зависимостей на этапе импорта вместо того, чтобы их выявлять. Вместо этого перенесите обращение в хук монтирования или спрячьте его за проверкой typeof window.
Почему «window is not defined» продолжает появляться после установки ssr: false в Next.js?
Две распространённые причины. В App Router next/dynamic принимает ssr: false только из клиентского компонента, и Next.js выдаёт ошибку, если эта опция встречается в серверном компоненте, — поэтому оберните его в тонкий компонент с 'use client'. Кроме того, ssr: false влияет только на этот конкретный динамический импорт: если другой файл, выполняемый на сервере, импортирует ту же библиотеку статически, её обращение к window на уровне модуля всё равно выполнится в Node.
Существует ли localStorage в Node.js?
Частично. В Node глобальный объект localStorage появился начиная с версии v22.4.0 и стал доступен без флага с v25.0.0; он сохраняет до 10 МБ в файле, переданном через флаг --localstorage-file; в v26 обращение к нему без этого флага выбрасывает DOMException. На сервере за ним стоит одно хранилище на весь процесс, а не по одному на посетителя или запрос, так что оно совершенно не похоже на браузерное хранилище, привязанное к пользователю, а window.localStorage по-прежнему выбрасывает ошибку, поскольку самого window в Node не существует.