12k
All articles

サーバーレンダリングアプリで発生する 'window is not defined' の修正

サーバー描画アプリのwindow is not definedを、on-mountフック、typeof windowチェック、依存関係のclient-only importで修正します。

OpenReplay Team
OpenReplay Team
サーバーレンダリングアプリで発生する 'window is not defined' の修正

window is not defined というエラーは、コードが window オブジェクトの存在しない Node.js で実行されたことを意味します。サーバーレンダリングを行うフレームワークは、ブラウザが関与する前に、まずサーバー上でコンポーネントを実行するのです。

このエラーは通常、正常に動作していたアプリにサーバーレンダリングを追加した直後、あるいはクライアントサイドで問題なく動いていたコンポーネントを Next、Nuxt、SvelteKit、Astro、React Router へ移した直後に現れます。コンポーネント自体は変わっていません。変わったのは「どこで実行されるか」であり、スタックトレースを見れば以下の3つの修正のうちどれが必要なのかが分かります。

重要なポイント

  • window is not defined は、コードが Node.js で実行されたことを意味します。Node.js ではどのライフサイクルのどの時点でも window は存在しません。つまりタイミングの問題ではありません。
  • 基本の修正方法は、アクセスをマウント時のフック(useEffectonMountedonMount)へ移すことです。マウントフックはサーバー上では決して実行されないからです。
  • typeof window !== 'undefined' によるガードは、モジュールレベルのコードや共有ユーティリティに適しています。コンポーネントのレンダー内で使うと、サーバーとクライアントの HTML が食い違ってしまいます。
  • クライアント専用レンダリングは最後の手段です。これはサーバーが生成する HTML からそのコンポーネントを完全に取り除きます。
  • 同じクラッシュはビルド時にも発生し得ます。静的生成では HTML を生成するためにコンポーネントを Node で実行するからです。

なぜサーバーレンダリングアプリで ‘window is not defined’ が起きるのか?

サーバーレンダリングアプリは、コンポーネントを2回実行します。まず HTML を生成するために Node.js で、その後もう一度ブラウザで実行します。Node.js のグローバルスコープには windowdocument も含まれていないため、サーバー側のパスでそれらに触れるコードは 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)

この区別が修正方法を決めます。インポート時のエラーはモジュールの読み込み時に発生するため、自分のコードでの利用箇所をマウントフックで包んでも解決しません。コンポーネントが存在する前にクラッシュしているからです。こうしたパッケージについては、修正3のクライアント専用インポートに進んでください。

修正1: アクセスをマウント時のフックへ移す

基本の修正方法は、window へのアクセスをフレームワークのマウント時フックへ移すことです。マウントフックはブラウザでしか実行されません。React の useEffect リファレンスはこの点を明示しています。サーバーレンダーは Effect をスキップし、コンポーネントがブラウザに到達して初めて実行されます。各フレームワークの対応は次のとおりです。Vue と Nuxt は onMounted、Svelte と SvelteKit は onMount(サーバーでレンダリングされたコンポーネントでは呼ばれません)、React Router は React の useEffect、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つの修正よりも既定の選択肢として優れています。

修正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 ではこの定数を、documentwindow に触れるものを隔離する標準的な方法として扱っています。

ただしコンポーネントのレンダー内では、このガードは不適切です。同じコンポーネントに対してサーバーとブラウザが異なる HTML を生成することになり、クラッシュの代わりにクライアント引き継ぎ時のミスマッチを招きます。ガードは通常の関数とモジュールスコープに留め、コンポーネント内では修正1を使いましょう。

修正3: そのコンポーネントのサーバーレンダリングを飛ばす

最後の手段は、クライアント専用の動的インポートで、コンポーネントをサーバーレンダリングから完全に除外する方法です。Next.js では ssr: false 付きの next/dynamic が Client Component 内でこれを行います(Server Component ではエラーになるため、薄い '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 が実行された時点でコンポーネントがどこからともなく現れ、その下にあるすべてをページ下方へ押しやります。上の 320px の div のような固定フットプリントのスケルトンは、実際のマークアップが到着するまでスペースを確保してくれます。そもそもプレースホルダーをレンダリングするか null にするかという判断は、多くのハイドレーションミスマッチの背後にあるトレードオフと同じもので、Next.js のハイドレーションエラー修正ガイドで詳しく扱っています。クライアント専用フォールバックのセッションリプレイでは、プレースホルダーからコンテンツへの入れ替わりがレイアウトのジャンプとして可視化されます。これは、プレースホルダーが本当に置き換え対象のマークアップと一致しているかを確認する最も手早い方法です。

あなたのケースにはどの修正が合うか?

  1. 自分のコードでコンポーネントが window を読んでいる場合: アクセスをマウント時のフックへ移します。既定の選択肢です。
  2. 共有ユーティリティやモジュールレベルの文が window に触れている場合: サーバー用のフォールバック値を持つ typeof window ガードを追加します。
  3. スタックトレースがインポート時の node_modules を指している場合: クライアント専用の動的インポートを使い、同じ形状のプレースホルダーを添えます。
  4. エラーがビルド出力にのみ現れる場合: 上と同じトリアージです。静的生成は Node で同一のコードパスを実行します。

まずスタックトレースを読む

このエラーはタイミングの問題ではなく、環境の問題です。あるコード行が、window が一度も存在したことのない Node で実行されたということです。まずスタックトレースを読みましょう。最上位フレームが自分のコードなら、マウント時フックかガードで、サーバー HTML を保ったまま修正できます。node_modules を指しているなら、その依存パッケージをクライアント専用インポートの背後に隔離し、レイアウトを保持するプレースホルダーを与えましょう。

FAQ

'document is not defined' は 'window is not defined' と同じ問題ですか?

はい。どちらのエラーも原因は同じで、window も document もグローバルスコープに含まない Node.js でコードが実行されたということです。同じトリアージと同じ3つの修正が適用できます。つまり、アクセスをマウント時のフックへ移す、モジュールレベルのコードを typeof チェックでガードする、依存パッケージがインポート時に DOM に触れる場合はコンポーネントをクライアント専用でレンダリングする、のいずれかです。

サーバー側でグローバルな window オブジェクトを定義してエラーを修正できますか?

避けてください。偽の window を globalThis に代入すれば ReferenceError は黙りますが、そのあとサーバーは偽の値からマークアップをレンダリングすることになり、ポリフィルに保存されたものはサーバーが処理するすべてのリクエストで共有されてしまいます。さらに、依存パッケージのインポート時クラッシュを表面化させる代わりに隠してしまいます。代わりに、アクセスをマウント時のフックへ移すか、typeof window ガードの背後に置きましょう。

Next.js で ssr: false を設定したのに 'window is not defined' がまだ出るのはなぜですか?

よくある理由が2つあります。App Router では next/dynamic が ssr: false を受け付けるのは Client Component からのみで、このオプションが Server Component に現れると Next.js はエラーを発生させます。そのため薄い 'use client' コンポーネントで包んでください。また、ssr: false はその動的インポートにしか影響しません。サーバーで実行される別のファイルが同じライブラリを静的にインポートしていれば、そのモジュールレベルの window アクセスは依然として Node で実行されます。

Node.js に localStorage は存在しますか?

部分的には存在します。Node は v22.4.0 以降 localStorage グローバルを提供しており、v25.0.0 からはフラグなしで利用でき、--localstorage-file フラグで渡したファイルに最大 10 MB まで永続化します。v26 ではそのフラグなしでアクセスすると DOMException が投げられます。ただしサーバー上ではその背後にあるストアはプロセス全体で1つであり、訪問者ごとやリクエストごとではありません。したがってブラウザのユーザー単位ストレージとはまったく異なります。また、Node では window 自体が決して存在しないため、window.localStorage は依然としてエラーを投げます。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.