😱

App Routerの落とし穴 サーバー側コードの混入を止めろ!編 [Next.js]

2022/10/26に公開

App Routerでは、今までサーバーサイドに書いていたコードを、間違えて露出する状況になるやもしれません。今回はその対策を語ります。

サーバーとクライアントコンポーネントの違い

App Routerは、React 18のサーバー/クライアントコンポーネントの概念を前提として実装されており、開発者にもその理解が要求されます。まずは以下のドキュメントを熟読しましょう。

https://nextjs.org/docs/getting-started/react-essentials

やりたいこと サーバーコンポーネント クライアントコンポーネント
コンポーネント内でデータを取得したい
バックエンドに直接アクセスしたい
サーバー上に機密情報を保持したい
アクセストークン APIキーなど
クライアント側のJSを減らしたい
双方向性やイベントリスナを付けたい
onClick() onChange()
ステートとライフサイクルを使いたい
useState() useReducer() useEffect()
ブラウザ専用のAPIを使いたい
状態、副作用、ブラウザ専用のAPIに
依存するカスタムフックを使いたい
クラスコンポーネントを使いたい

特に注意してほしいのは、イベントリスナーや状態変化に依存するコンポーネントは、'use client'でクライアントコンポーネントだと明示する必要がある点です。

また、明確にgetXXXPropsに分離できていた通信関連の関数を、必ずサーバーコンポーネントから呼ぶ必要が生じます。

import サーバーコンポーネント
(デフォルト)
クライアントコンポーネント
(use client)
サーバー想定コード
(A: CORSエラーや意図しない漏洩の発生)
クライアント想定コード
(B: windowを使ってしまった等の場合)

この区別を蔑ろにすると、意図しないコードの混入が発生したが、ビルドが通ってしまって気づかない 場合がこれまで以上に増えるでしょう。

この記事ではAとBのパターンで確実にビルドを止める方法を解説していきます。

サーバー限定コードが誤ってクライアント側に混入する場合

import クライアントコンポーネント
(use client)
サーバー想定コード
(A: CORSエラーや意図しない漏洩の発生)

通信が含まれていれば、大抵はビルド時にCORSエラーで引っかかり気づくのですが、そうならない場合、エラーなくビルドを終えて、page-XXX.jsにサーバー側のコードをバンドルしてしまいます。

pages/index.tsx
export async function geStaticProps() {
  // 無防備にURLを書いてしまった
  const posts = await fetch(
    'https://jsonplaceholder.typicode.com/posts'
  ).then(async (res) => {
    return await res.json();
  });
  return {
    props: {
      posts: posts ?? null,
    },
  };
}

例えば上記の関数を、getStaticPropsからApp Routerに移動するとしましょう。

ClientComponent.tsx (※真似しないでね)
'use client';

import { use } from 'react';

export async function getPosts() {
  // 無防備にURLを書いてしまった
  return await fetch('https://jsonplaceholder.typicode.com/posts').then(
    async (res) => {
      return await res.json();
    }
  );
}

export default function ClientComponent() {
  const posts = use(getPosts());
  return <div>{JSON.stringify(posts)}</div>;
}

ヨシ!

...ありゃ、これはuse clientを明示したクライアントコンポーネントじゃないですか!

ビルドが通ってしまった

でもビルドは通ってしまいました。

コード混入の結果

コード混入の結果

上記のままビルドすると、何が起こるか? Pagesの時はクライアントサイドに含まれなかったURLが、ブラウザで閲覧できてしまいます。

その前に型安全な環境変数を用意しよう

...そもそも環境変数使えやという話ですね。

https://zenn.dev/temasaguru/articles/406189a014b656

この t3-env を使えば、環境変数の混入を防ぐ大きな手助けになります。ぜひ導入しましょう。


ここから本文。

server-onlyパッケージでビルドを止めよう

とにかく想定していない漏洩を発生させないために、そもそもビルド時に例外を投げる方向でいきましょう。

https://beta.nextjs.org/docs/rendering/server-and-client-components#keeping-server-only-code-out-of-client-components-poisoning

こんなときのためにserver-onlyというパッケージが用意されています。

npm i server-only
lib/api.ts
+ export async function getPosts() {
+   // 無防備にURLを書いてしまった
+   return await fetch('https://jsonplaceholder.typicode.com/posts').then(
+     async (res) => {
+       return await res.json();
+     }
+   );
+ }
ClientComponent.tsx
'use client';

import { use } from 'react';
- export async function getPosts() {
-   // 無防備にURLを書いてしまった
-   return await fetch('https://jsonplaceholder.typicode.com/posts').then(
-     async (res) => {
-       return await res.json();
-     }
-   );
- }
+ import { getPosts } from '@/lib/api';

export default function ClientComponent() {
  const posts = use(getPosts());
  return <div>{JSON.stringify(posts)}</div>;
}

まず、サーバーサイド部分はファイルを分けてください。

lib/api.ts
+ import 'server-only';

export async function getPosts() {
  // 無防備にURLを書いてしまった
  return await fetch('https://jsonplaceholder.typicode.com/posts').then(
    async (res) => {
      return await res.json();
    }
  );
}

サーバーを想定したファイルのいずれかの場所で、import 'server-only'するだけです。

この状態ではビルドが通りません。下記のエラーが出ます。

Failed to compile.

./src/lib/api.ts
ReactServerComponentsError:

You're importing a component that needs server-only. That only works in a Server Component but one of its parents is marked with "use client", so it's a Client Component.

   ╭─[/home/temasaguru/repositories/temasaguru/app-router-playground/src/lib/api.ts:1:1]
 1 │ import 'server-only';
   · ─────────────────────
 2 │ 
 3 │ export async function getPosts() {
 4 │   // 無防備にURLを書いてしまった
   ╰────

One of these is marked as a client entry with "use client":
  ./src/lib/api.ts
  ./src/components/ClientComponent.tsx

You're importing a component that needs server-only. That only works in a Server Component but one of its parents is marked with "use client", so it's a Client Component.
(サーバー限定コンポーネント(というか関数)をインポートしてますよ。それはサーバーコンポーネントだけで動きますが、use clientなのでそれクライアントコンポーネントです)

ビルドが止まるようになりました。ローカルで動かしている際もエラーになります。

クライアント限定コードが誤ってサーバー側に混入する場合

import サーバーコンポーネント
(デフォルト)
クライアント想定コード
(B: windowを使ってしまった等の場合)

逆のパターンで間違えた場合はどうでしょうか。

そもそも開発の段階で動かない

client-only.ts
export function getWindowSize() {
  return [window.innerWidth, window.innerHeight];
}
ServerComponent.tsx
import { getWindowSize } from '../lib/client-only';

/**
 * 動くわけがない
 */
export default function ServerComponent() {
  const [width, height] = getWindowSize();
  return (
    <div>
      ウィンドウサイズ: {width}x{height}
    </div>
  );
};

App Routerのデフォルトはサーバーコンポーネントですから、これは動くわけ無いですね。

> next build

- info Loaded env from /home/temasaguru/repositories/temasaguru/app-router-playground/.env.local
- info Creating an optimized production build  
- info Compiled successfully
- info Linting and checking validity of types  
- info Collecting page data  
[    ] - info Generating static pages (0/5)ReferenceError: window is not defined

そもそも開発の段階で動かない・ビルドできないため、「クライアント用をサーバー用に書いてしまう」ミスの対策に下記パッケージが必要かは疑問が残ります。

window is not defined と言われて「クライアントコンポーネントにしてないや!」と気づける自信がない場合のみ、以下に従ってください。


client-only

npm i client-only

client-only というパッケージをインストールしてみましょう。

client-only.ts
+ import 'client-only';

export function getWindowSize() {
  return [window.innerWidth, window.innerHeight];
}

server-onlyと同様に、該当ファイルにimportするだけです。

You're importing a component that imports client-only. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.
(クライアント限定コンポーネント(というか関数)をインポートしてますよ。それはクライアントコンポーネントだけで動きますが、親のどれもuse clientを使ってないので、それらはデフォルトでサーバーコンポーネントです)

ビルドが適切なエラーメッセージとともに止まるようになりました。(変化がメッセージだけなので、導入の必要性は薄いと思います)


以上、ビルドを確実に止める方法でした。

その他の落とし穴

https://zenn.dev/temasaguru/articles/546f0fcdd9d131

https://zenn.dev/temasaguru/articles/5d037825dc192f

https://zenn.dev/temasaguru/articles/6e6b47a34d9855

Discussion