Next.js(App Router / 静的エクスポート)に TanStack Query を導入する手順

目次
App Router の静的エクスポート(output: "export")なプロジェクトに TanStack Query(React Query) を入れました。SPA なのでデータ取得はすべてクライアント側。「Provider をどこに置く」「fetch をどう共通化する」「hooks をどう分ける」を、実際に組んだ順にまとめます。
結論の構成はこう:
app/
├── lib/
│ ├── tanstack/index.tsx # QueryClient の Provider("use client")
│ └── api.ts # 共通 fetch
├── hooks/
│ └── useHealth.ts # 1ファイル1フック。queryFn は api 関数を呼ぶだけ
└── layout.tsx # Provider でラップ
1. インストール
pnpm add @tanstack/react-query
# 任意(開発時のデバッグUI)
pnpm add @tanstack/react-query-devtoolsReact 19 に対応した v5 が入ります。
2. Provider を Client Component で初期化する
QueryClientProvider は Context を使うので、Client Component("use client")で用意します。ポイントは QueryClient を useState の初期化関数で1回だけ生成すること。モジュールトップで new QueryClient() するとインスタンスが共有・再生成されて事故るので避けます。
// app/lib/tanstack/index.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
const Providers = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分は再フェッチしない
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
export default Providers;ReactNode は React.ReactNode ではなく named import にしています。React 17+ の自動 JSX ランタイムで import React が不要になったので、React 名前空間に依存しない書き方に揃える方がモダンです。
defaultOptions のよく使う項目
| オプション | 意味 |
|---|---|
staleTime |
何 ms 鮮度を保つ(この間は再フェッチしない) |
gcTime |
未使用キャッシュを破棄するまでの時間(旧 cacheTime) |
retry |
失敗時のリトライ回数 |
refetchOnWindowFocus |
フォーカス復帰で再フェッチ(既定 true。うるさいので切ることが多い) |
3. layout でラップ
layout.tsx(Server Component のままでOK)で、<body> の中を Provider で包みます。Client Component の Provider を Server Component から描画するのは問題ありません。
// app/layout.tsx
import type { ReactNode } from "react";
import Providers from "./lib/tanstack";
const RootLayout = ({ children }: Readonly<{ children: ReactNode }>) => {
return (
<html lang="ja">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
};
export default RootLayout;4. 共通 fetch を切り出す(lib/api.ts)
queryFn に毎回 fetch + エラー処理 + base URL を書くのは冗長なので、共通関数を1つ用意します。非 2xx を throw しておくと、TanStack Query 側が自動で error 状態にしてくれます。
// app/lib/api.ts
const BASE = process.env.NEXT_PUBLIC_API_ENDPOINT;
// Content-Type は body があるときだけ付ける(GET に付けると余計なプリフライトが飛ぶため)。
export const apiFetch = async <T>(path: string, init?: RequestInit): Promise<T> => {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
return res.json() as Promise<T>;
};base URL は NEXT_PUBLIC_ プレフィックスの env(ビルド時に埋め込まれる)で渡します。静的エクスポートではサーバーが無いので、これで十分です。
5. hooks は「1ファイル1フック」、queryFn は api 関数を呼ぶだけ
useQuery を直接コンポーネントに書くと再利用しづらいので、hooks ディレクトリに1ファイル1フックで切り出します。queryFn には fetch ロジックを持たせず、apiFetch を呼ぶだけにします。
// app/hooks/useHealth.ts
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
type Health = { message: string };
export const useHealth = () =>
useQuery({
queryKey: ["health"],
queryFn: () => apiFetch<Health>("/health"),
});queryKey は「データ単位」でユニークに
queryKey はキャッシュのキーです。関数ごとではなく取得するデータ(リソース+パラメータ)ごとにユニークにします。
- 同じデータ → 同じキーにする(別コンポーネントで呼んでもキャッシュ共有&リクエスト重複排除)
- パラメータ付き → キーに含める(
["items", id]のように)。入れ忘れると id を変えても古いキャッシュが返る
["items"] // 一覧
["items", id] // 詳細(id ごとに別キャッシュ)先頭にエンティティ名を置いておくと、invalidateQueries({ queryKey: ["items"] }) で前方一致の一括無効化がしやすくなります。
6. コンポーネントで使う
hooks を呼ぶコンポーネントは Client Component("use client")です。isLoading / error / data を出し分けます。
"use client";
import { useHealth } from "@/hooks/useHealth";
export const Health = () => {
const { data, isLoading, error } = useHealth();
if (isLoading) return <p>loading...</p>;
if (error) return <p>error</p>;
return <p>{data?.message}</p>;
};この Client Component は、page.tsx(Server Component)から描画(JSX で配置)するのはOKです。フックを Server Component で直接呼ぶのは NG(フックはクライアントでしか動かない)なので、「呼ぶ場所」と「描画する場所」を分けるのがコツです。
静的エクスポートでの注意
- サーバーが無いので、SSR プリフェッチ/hydration(
HydrationBoundary/dehydrate)は使わず、クライアント専用でシンプルに。 - ビルド時プリレンダーでは
useQueryは loading 状態の HTML になり、ブラウザで hydrate 後にフェッチ→表示、という流れ。
今後の増やし方
「1ファイル1フック」を維持すると増えても迷いません。
- クエリ追加 →
app/hooks/useItems.ts/useItem.ts - ミューテーション →
app/hooks/useCreateItem.ts(中でapiFetch("/items", { method: "POST", body }))
fetch の実処理は lib/api.ts に集約されているので、hooks は「キー」と「どの api を呼ぶか」だけの薄い層に保てます。
まとめ
- Provider は Client Component、
QueryClientはuseState初期化で1回だけ生成 layout.tsxの<body>を Provider でラップ(layout 自体は Server Component のまま)- 共通 fetch を
lib/api.tsに切り出し、非 2xx は throw - hooks は1ファイル1フック、
queryFnは api 関数を呼ぶだけの薄い層に queryKeyはデータ単位でユニーク、パラメータはキーに含める- 静的エクスポートはクライアント専用でシンプルに
参考ソース
関連記事

保存(Ctrl+S)のたびに未使用importを消し、importの並び順も自動で揃える設定を、ESLint flat config で作ります。eslint-plugin-unused-imports と simple-import-sort、VS Code の codeActionsOnSave、Prettier との併用・順序までまとめます。

create-next-app から Amplify Gen2 のバックエンド(Cognito/DynamoDB/Lambda)を追加し、Server Actions での SSR 書き込み、Lambda を API Gateway で REST 公開、Next.js API Route、Amplify Hosting の Git 連携デプロイまでを一気通貫でまとめます。

git ルートに husky を置き、frontend の ESLint(no-console・any 禁止・未使用/import 整列)と Prettier(Tailwind クラス整列)を lint-staged 経由で pre-commit 自動実行する構成を、実際の設定ファイルとハマりどころつきで解説します。