useSearchParams() でハマる Suspense 地獄と対処法【Next.js App Router】
はじめに
Next.js(App Router)で useSearchParams() を使った瞬間、「Missing a Suspense boundary」 でページが爆散——
この“Suspense地獄”は、クライアントコンポーネントでルーター状態を読むときは非同期境界(<Suspense>)が必要という仕様に起因します。
この記事は、よくある落とし穴→最短の修正→ベストプラクティス、の順に整理した実務向けサバイバルガイドです。
0. TL;DR(最短修正)
-
useSearchParams()を呼び出すコンポーネントを<Suspense>で包む。 - もしくは Server Component の
searchParams引数で受け取り、Client に props で渡す。 - ルール:**「フックを呼ぶ場所」**に Suspense が必要。親ページに置くだけでは足りないケースあり。
1. 典型的なエラーメッセージ
Error: useSearchParams() is missing a Suspense boundary.
または
You're attempting to read the router state without a suspense boundary.
2. なぜ Suspense が必要なのか
-
useSearchParams()は **クライアント側でルーター状態(URLクエリ)**を読みます。 - ルーター状態は 遷移時に非同期に変化するため、React は「待機点(Suspense boundary)」がないと安全にレンダーできません。
- つまり 「待つ場所(
<Suspense>)を用意せよ」 が要件。
サーバーコンポーネントで
searchParamsを受ければ、SSRの確定値として扱えるので Suspense 不要。
クライアントで直接読むなら、遷移と同時に変わるので Suspense が必要。
3. 最小再現コード(NG例)
// app/items/page.tsx (Server Component)
import { ItemList } from "./_components/ItemList";
export default function Page() {
return <ItemList />; // ← 子が useSearchParams を呼ぶ
}
// app/items/_components/ItemList.tsx (Client Component)
"use client";
import { useSearchParams } from "next/navigation";
export function ItemList() {
const sp = useSearchParams(); // ← ここで爆発
const q = sp.get("q") ?? "";
return <div>Query: {q}</div>;
}
4. 解決策その1:呼び出し元を <Suspense> で包む(最短)
// app/items/page.tsx
import { Suspense } from "react";
import { ItemList } from "./_components/ItemList";
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ItemList />
</Suspense>
);
}
ポイント
-
useSearchParams()を 実際に呼ぶコンポーネントを<Suspense>配下に置くこと。 - ページ上位に
<Suspense>を置いても、境界の外で読んでいれば無効。
5. 解決策その2:Server Component で受けて Client に props で渡す(堅牢)
// app/items/page.tsx (Server Component)
import { ItemListClient } from "./_components/ItemListClient";
export default function Page({ searchParams }: {
searchParams?: { [key: string]: string | string[] | undefined }
}) {
const q = typeof searchParams?.q === "string" ? searchParams.q : "";
return <ItemListClient q={q} />; // ← 確定値として渡す
}
// app/items/_components/ItemListClient.tsx (Client Component)
"use client";
export function ItemListClient({ q }: { q: string }) {
return <div>Query: {q}</div>;
}
メリット
- クライアント側では普通の propsとして扱えるため、Suspense 不要。
- SSR キャッシュや SEO 面でも安定。
6. 解決策その3:呼び出し箇所の“直上”に Suspense 境界を置く
// app/items/_components/ItemList.tsx (Client)
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
function Inner() {
const sp = useSearchParams();
const q = sp.get("q") ?? "";
return <div>Query: {q}</div>;
}
export function ItemList() {
return (
<Suspense fallback={<div>Loading list...</div>}>
<Inner />
</Suspense>
);
}
解説
- 呼び出し関数を分割し、useSearchParams を呼ぶ関数の直上に Suspense を置く。
- 複雑なレイアウトで「どこに境界を置けば良いか」迷ったときの実務テク。
7. URL同期UIでの実用レシピ(検索フォーム+クエリ連動)
// app/search/page.tsx (Server)
import { Suspense } from "react";
import { SearchPanel } from "./_components/SearchPanel";
export default function Page() {
return (
<Suspense fallback={<div>Loading search...</div>}>
<SearchPanel />
</Suspense>
);
}
// app/search/_components/SearchPanel.tsx (Client)
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
export function SearchPanel() {
const router = useRouter();
const sp = useSearchParams();
const q = sp.get("q") ?? "";
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const next = new URLSearchParams(sp); // 既存を引き継ぐ
next.set("q", String(form.get("q") ?? ""));
router.push(`/search?${next.toString()}`);
},
[router, sp]
);
const placeholder = useMemo(() => (q ? `Now: ${q}` : "Type query..."), [q]);
return (
<form onSubmit={handleSubmit}>
<input name="q" defaultValue={q} placeholder={placeholder} />
<button type="submit">Search</button>
</form>
);
}
コツ
-
URLSearchParams(sp)で既存クエリを保ったまま編集。 - ルーター遷移が起きるため、Suspense 必須(Page 側で包む)。
8. 「包んだけどまだ怒られる」時のデバッグ手順
-
どのコンポーネントで
useSearchParamsを呼んでいるか特定する。 - その 直上に
<Suspense>があるか 確認(祖先に置けば良いわけではない)。 -
useSearchParamsを 関数外で参照していないか(例:モジュールスコープで呼ぶのはNG)。 -
Server/Client の境界が正しいか(
"use client"の位置が過不足ないか)。 - それでもダメなら 解決策その2(Serverで受けてprops渡し)に切替。
9. よくある設計の分岐基準
-
SEO必要/SSRで確定値が欲しい → Serverの
searchParamsで受ける(推奨) -
ClientだけでURL状態を直接読みたいUI → 最小範囲で
<Suspense>を被せる - 表層は安定・内側でURL連動 → 「呼ぶ場所の直上」に境界
10. 型定義テンプレ(コピペ用)
// page.tsx(Server Component)での型
export default function Page({
searchParams,
}: {
searchParams?: { [key: string]: string | string[] | undefined };
}) {
// 型安全に取り出す例
const q =
typeof searchParams?.q === "string"
? searchParams.q
: Array.isArray(searchParams?.q)
? searchParams!.q[0] ?? ""
: "";
// ...
}
まとめ
-
useSearchParams()はクライアントで使うなら Suspense 必須。 - 最も堅いのは Server で受けて props で渡す設計。
- 迷ったら「フックを呼ぶ場所の直上に境界」を置く。
これで“Suspense地獄”から脱出できます。ハッピーURLライフを。