App Routerの落とし穴 サーバー側コードの混入を止めろ!編 [Next.js]
App Routerでは、今までサーバーサイドに書いていたコードを、間違えて露出する状況になるやもしれません。今回はその対策を語ります。
サーバーとクライアントコンポーネントの違い
App Routerは、React 18のサーバー/クライアントコンポーネントの概念を前提として実装されており、開発者にもその理解が要求されます。まずは以下のドキュメントを熟読しましょう。
やりたいこと | サーバーコンポーネント | クライアントコンポーネント |
---|---|---|
コンポーネント内でデータを取得したい | ✅ | ❌ |
バックエンドに直接アクセスしたい | ✅ | ❌ |
サーバー上に機密情報を保持したい アクセストークン 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
にサーバー側のコードをバンドルしてしまいます。
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に移動するとしましょう。
'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が、ブラウザで閲覧できてしまいます。
その前に型安全な環境変数を用意しよう
...そもそも環境変数使えやという話ですね。
この t3-env
を使えば、環境変数の混入を防ぐ大きな手助けになります。ぜひ導入しましょう。
ここから本文。
server-onlyパッケージでビルドを止めよう
とにかく想定していない漏洩を発生させないために、そもそもビルド時に例外を投げる方向でいきましょう。
こんなときのためにserver-only
というパッケージが用意されています。
npm i server-only
+ export async function getPosts() {
+ // 無防備にURLを書いてしまった
+ return await fetch('https://jsonplaceholder.typicode.com/posts').then(
+ async (res) => {
+ return await res.json();
+ }
+ );
+ }
'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>;
}
まず、サーバーサイド部分はファイルを分けてください。
+ 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 を使ってしまった等の場合) |
逆のパターンで間違えた場合はどうでしょうか。
そもそも開発の段階で動かない
export function getWindowSize() {
return [window.innerWidth, window.innerHeight];
}
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
というパッケージをインストールしてみましょう。
+ 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
を使ってないので、それらはデフォルトでサーバーコンポーネントです)
ビルドが適切なエラーメッセージとともに止まるようになりました。(変化がメッセージだけなので、導入の必要性は薄いと思います)
以上、ビルドを確実に止める方法でした。
その他の落とし穴
Discussion