Next.js App RouterのServer Componentsで3週間詰まった話【RSC初心者あるある失敗5選】

Next.js App RouterでReact Server Componentsを使い始めて3週間、同じようなエラーを繰り返した体験記。useClient忘れ・State使用・キャッシュ挙動など典型的な罠と正解ルートを解説。

React Server ComponentsNext.jsApp RouterRSCNext.js初心者フロントエンド

※当サイトはアフィリエイトプログラムに参加しています。記事内のリンクから商品を購入すると、当サイトに報酬が支払われることがあります。詳しくはプライバシーポリシーをご覧ください。

Next.jsの開発画面

はじめに:「え、なんで動かないの…」を3週間繰り返した話

4月の最初の週に、久しぶりに新しいNext.jsプロジェクトをゼロから立ち上げた。

クライアントの案件で「App Routerを使いたい」と言われたのが発端です。それまで僕はPages Routerしか触ったことがなかったので、正直かなり身構えた。

結論から言うと、App RouterとReact Server Componentsは「Page Routerの延長線上」ではなく、ほぼ別物です。

その認識がなかったせいで、最初の3週間は本当にひどかった。同じようなエラーを何度も出して、毎回「あれ、なんでだっけ」って1〜2時間くらい溶かしていた。

この記事は、そのときの失敗記録です。同じところでハマっている人の時間を、少しでも節約できたら嬉しい。

(GWも近いし、連休中にApp Routerを触ってみようとしている人もいるんじゃないかと思って、今このタイミングで書いています。)


前提:Server ComponentsとClient Componentsの「本質的な違い」

失敗の話に入る前に、ここだけ確認させてください。

App Routerの世界では、Reactコンポーネントはデフォルトでサーバー上でしか動かない

これが、Pages Routerとの最大の違いです。

特徴Server ComponentsClient Components
実行場所サーバーブラウザ
useState / useEffect使えない使える
fetch(直接)使える使えない(APIルート経由が必要)
ブラウザAPI使えない使える
宣言方法何も書かない(デフォルト)'use client' を先頭に書く

これを頭に入れた上で、失敗談を読んでください。


罠1:useStateを書いたら「You’re importing a component that needs useState」エラー

何が起きたか

新しいコンポーネントを作って、useStateでフォームの状態を管理しようとした。

// src/app/contact/page.tsx(ダメな例)
import { useState } from 'react';

export default function ContactPage() {
  const [name, setName] = useState('');

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
    </form>
  );
}

エラー文:

You're importing a component that needs useState. It only works in a Client Component
but none of its parents are marked with "use client", so they're all Server Components by default.

なぜ起きるか

App Routerでは、src/app/ 以下のコンポーネントはデフォルトでServer Componentsです。Server ComponentsはNode.jsのサーバー上で動くので、ブラウザのReact機能(useStateuseEffect・イベントハンドラ)は使えない。

正解ルート

'use client' を先頭に書くだけ。

// src/app/contact/page.tsx(正しい例)
'use client';

import { useState } from 'react';

export default function ContactPage() {
  const [name, setName] = useState('');

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
    </form>
  );
}

ここがポイントなんですが'use client' はファイルの本当に先頭(importより上)に書かないと動きません。コメントやimport文の後に書いてしまって「なぜか効かない」という目に、僕は一度遭いました。


罠2:Server Componentの中にClient Componentを直接importしたらエラー

何が起きたか

Server ComponentとClient Componentを組み合わせれば効率よく使えると思って、こんなコードを書いた。

// src/app/dashboard/page.tsx(ダメな例)
// このファイルはServer Component('use client'なし)

import InteractiveWidget from '@/components/InteractiveWidget'; // Client Component

async function getData() {
  const res = await fetch('https://api.example.com/data');
  return res.json();
}

export default async function DashboardPage() {
  const data = await getData();

  return (
    <div>
      <h1>ダッシュボード</h1>
      {/* Propsとしてfetch結果のオブジェクトをそのまま渡した */}
      <InteractiveWidget data={data} />
    </div>
  );
}
// src/components/InteractiveWidget.tsx
'use client';

export default function InteractiveWidget({ data }: { data: any }) {
  // ...
}

一見問題なさそうに見えるんですが、data の中身が「シリアライズできないオブジェクト」だとエラーになります。

なぜ起きるか

Server ComponentからClient ComponentにPropsを渡す場合、そのデータはシリアライズ(JSON化)できる形式でないといけない。クラスインスタンス・関数・DateオブジェクトなどをそのままPropsに渡すと爆発する。

正解ルート

Propsに渡すデータは、JSON.stringifyできる形に変換してから渡す。または、データ取得ロジック自体をClient Component側に移す(SWRやReact Queryを使う)。

// Server Componentからは「プリミティブな値」だけ渡す
export default async function DashboardPage() {
  const data = await getData();

  return (
    <div>
      <InteractiveWidget
        id={data.id}           // string OK
        title={data.title}     // string OK
        count={data.count}     // number OK
        // createdAt={data.createdAt} // Date型はNG → .toISOString()に変換
        createdAt={data.createdAt.toISOString()}
      />
    </div>
  );
}

罠3:fetchのキャッシュが効きすぎて、データが更新されない

エラー画面

何が起きたか

これが一番ハマった。正直、当時の僕は「え、fetch呼んでるのになんでデータ古いの?」という状態で、2〜3時間くらいうろうろしていた。

// src/app/posts/page.tsx
export default async function PostsPage() {
  // デフォルトだとNext.jsがこのレスポンスをキャッシュする
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return (
    <ul>
      {posts.map((post: any) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

管理画面でデータを更新しても、ページをリロードしても古いデータが表示される。「え、ブラウザのキャッシュ?」とChromeのキャッシュをクリアしても変わらない。

なぜ起きるか

Next.js App Routerは、Server Components内のfetchのレスポンスをデフォルトでキャッシュします。これは「同じURLへのfetchは1回だけ実行し、結果を再利用する」という最適化。

問題は、このキャッシュが思ったより強力で、ビルド時にも適用されること。本番デプロイ後にAPIのデータが変わっても、キャッシュが残っていると古いデータを返し続ける。

正解ルート

ユースケースに合わせてキャッシュ戦略を明示する。

// パターン1: キャッシュしない(常に最新データを取得)
const res = await fetch('https://api.example.com/posts', {
  cache: 'no-store'
});

// パターン2: 一定時間ごとに再検証(ISR的な動作)
const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 } // 60秒ごとに再検証
});

// パターン3: revalidateタグを使って手動で無効化
const res = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] } // 'posts'タグを持つキャッシュをまとめて無効化できる
});

(ちょっと話逸れるけど、このキャッシュ戦略の選択って、Supabase + Next.jsの構成を使うときにも同じ問題が起きます。Supabaseのデータが更新されてもページに反映されない、みたいな。)


罠4:useRouterusePathnameuseSearchParamsが使えない

何が起きたか

現在のURLパラメータを読みたくて、useSearchParamsを使おうとした。

// src/app/search/page.tsx(ダメな例)
import { useSearchParams } from 'next/navigation';

export default function SearchPage() {
  const searchParams = useSearchParams();
  const query = searchParams.get('q');

  return <div>検索ワード: {query}</div>;
}

エラー:

useSearchParams() should be wrapped in a suspense boundary at the page "/search".
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

なぜ起きるか

useSearchParamsをはじめとしたnext/navigationのhookは、Client Componentsでしか使えません。かつ、useSearchParamsを使うコンポーネントは<Suspense>で囲む必要がある。

正解ルート

// src/app/search/page.tsx(正しい例)
import { Suspense } from 'react';
import SearchResult from '@/components/SearchResult';

export default function SearchPage() {
  return (
    <Suspense fallback={<div>ローディング中...</div>}>
      <SearchResult />
    </Suspense>
  );
}
// src/components/SearchResult.tsx
'use client';

import { useSearchParams } from 'next/navigation';

export default function SearchResult() {
  const searchParams = useSearchParams();
  const query = searchParams.get('q');

  return <div>検索ワード: {query}</div>;
}

useSearchParamsを使うコンポーネントを切り出して、それを<Suspense>で包んだ親コンポーネントから呼び出す構造にする。


罠5:Server ComponentからServer Actionを呼ぼうとして混乱した

何が起きたか

フォームの送信処理をServer Actionで書こうとしたのだが、どのファイルにどう書けばいいかで混乱した。

// src/app/contact/page.tsx(混乱した状態)
'use client'; // フォームのためにClient Componentにした

// でも、Server Actionはどこに書けば...?
// このファイル内に書いていいの?

async function submitForm(formData: FormData) {
  'use server'; // ← Client Componentのファイル内にこれを書いたらどうなる?
  // ...
}

正解ルート

Server Actionは別ファイルに切り出すのが基本。

// src/app/actions.ts(Server Action専用ファイル)
'use server';

export async function submitForm(formData: FormData) {
  const name = formData.get('name') as string;
  // DBへの保存など
  console.log('サーバー側で受け取った:', name);
}
// src/app/contact/page.tsx(Client Component)
'use client';

import { submitForm } from '@/app/actions';

export default function ContactPage() {
  return (
    <form action={submitForm}>
      <input name="name" />
      <button type="submit">送信</button>
    </form>
  );
}

ここがポイントなんですが'use server' を持つ関数は、Client Componentのファイル内には書けません(技術的には書けるが、モジュールレベルで 'use server' を宣言したファイルでないと意図通りに動かないケースがある)。専用の actions.ts に切り出すのが最も安全です。


正解ルートのまとめ:RSCで迷わないための判断フロー

コードレビュー

3週間の失敗から学んだ、「Server ComponentかClient Componentか」の判断フローです。

新しいコンポーネントを作る

useState / useEffect / イベントハンドラが必要?
  YES → 'use client' を書く(Client Component)
  NO  ↓
      
  fetch / DBアクセス / 秘匿APIキーを使う?
  YES → Server Componentのまま(デフォルト)
  NO  ↓
  
  ブラウザAPIが必要(window / document / localStorage)?
  YES → 'use client' を書く
  NO  → Server Componentのまま(デフォルト)

基本的には「必要になったら'use client'を付ける」という後付けアプローチで問題ありません。最初から全部Client Componentにしてしまうと、Server Componentsのメリット(サーバー側でのデータ取得・バンドルサイズ削減)が消えてしまうので要注意。


ここまで読んでくれた人に正直に言うと

App Routerは、正直最初の1〜2週間はかなりつらいです。

エラーメッセージは英語だし、「Server Component / Client Component」という概念が頭に染み付くまで、毎回「どっちだっけ」ってなります。

でも、仕組みが分かってくると、Pages Routerより明らかに設計がクリーンだと感じるようになります。「このコンポーネントはサーバー側、あっちはクライアント側」と責任が明確になるので、大きなプロジェクトになるほどメリットが出てくる。

関連して、状態管理の選択についても迷いがちです。RSCを使いながらクライアント側の状態管理をどうするかは、Zustand vs Redux の比較記事が参考になります。また、そもそも「Next.jsを選ぶべきかどうか」という段階の方は、Next.js vs React/CRA の比較記事を先に読むといいかもしれません。

TypeScriptの型エラーがRSCの罠と絡まってくるケースも多いので、TypeScript入門記事も合わせて読んでおくと理解が早いです。


よくある質問

Q: Server ComponentsとClient Components、どっちをメインに使えばいいですか?

A: 基本的にはServer Componentsをデフォルトとして使い、インタラクション(クリック・フォーム・アニメーション)が必要な部分だけClient Componentsにするのがおすすめです。App Routerの設計思想がそういう方向を向いています。Client Componentsを多用すると、Pages Routerと変わらなくなってしまいます。

Q: ‘use client’ディレクティブはどのファイルに書けばいいですか?

A: useStateuseEffect・イベントハンドラ・ブラウザAPIを使うファイルの先頭に書きます。'use client'は「境界」を定義するもので、そのファイルと、そのファイルがimportするコンポーネント全体がClient Component扱いになります。できるだけツリーの末端(葉)に近いコンポーネントに書くのがベストプラクティスです。

Q: Server Componentsでfetchするとキャッシュされすぎて困るんですが、どうすれば?

A: fetchオプションで cache: 'no-store'(キャッシュしない)または next: { revalidate: N }(N秒ごとに再検証)を指定するのが最速の解決策です。常にリアルタイムデータが必要なAPIにはcache: 'no-store'を、ある程度の静的さが許容できるデータにはrevalidateを使い分けるのが現実的です。

Q: Page RouterからApp Routerに移行するのは難しいですか?

A: 難しいです、正直に言うと。特にgetServerSidePropsgetStaticPropsの代替パターンを理解するまでが一番しんどい。ただ、新規プロジェクトからApp Routerで始めるのは今がベストタイミングだと思います。移行コストについてはNext.jsのデプロイ比較記事でも少し触れています。


あわせて読みたい


【2026年4月追記】この記事を書いた後、Next.js 15.2のリリースがあり、エラーメッセージが少し改善されました。「You’re importing a component that needs…」系のエラーが、より具体的に「どのファイルの何行目が問題か」を示してくれるようになっています。ただ、罠の本質は変わっていないので、この記事の内容はそのまま有効です。GW中にApp Routerを触ってみる方、ぜひ参考にしてください。

【買い切り型】Colosoでプロから学ぶ

業界トップの講師によるオンライン講座。買い切り型で何度でも復習可能。受講期限なし。

講座一覧を見る →