- JavaScript
- 技術 💻
Next.js App Router 入門|v16対応・実際のビルド結果つき
Next.js の App Router とは、app フォルダーの中にフォルダーとファイルを置くだけで URL と画面の構成が決まる、Next.js の標準のルーティング方式です。React Server Components(サーバー側で描画する部品)を前提にしており、データ取得・キャッシュ・フォーム送信までを、ひとつの考え方で書けるのが特徴です。
App Router の入門記事は多くありますが、Next.js 13.4 の頃の情報のままだったり、ルーティングだけで終わっていたりするものも見かけます。この記事は、2026年9月時点の最新版 Next.js 16.3.6 で実際に小さなブログアプリを作り、npm run build の出力(どのページが静的で、どれが動的か)や、よく出るビルドエラーの実物まで載せています。キャッシュの仕組みは Next.js 16 で大きく変わったため、公式ドキュメントに沿って「従来のモデル」と「Cache Components」の両方を、実行結果つきで整理しました。
Next.js App Router の早見表
| やりたいこと | App Router での書き方 |
|---|---|
| ページを作る | app/about/page.tsx → /about |
| 全ページ共通の枠(ヘッダーなど) | app/layout.tsx(ルートレイアウト。必須) |
| 読み込み中の表示 | loading.tsx(自動で Suspense になる) |
| エラー時の表示 | error.tsx('use client' が必須) |
| 404ページ | not-found.tsx と notFound() |
| URLの一部を変数にする | app/posts/[id]/page.tsx(params は Promise なので await) |
| ボタンや入力など、ブラウザで動く部品 | ファイル先頭に 'use client' |
| データ取得 | async の Server Component の中で await |
| フォーム送信・更新処理 | Server Actions('use server') |
| APIを作る | app/api/hello/route.ts に GET などを export |
| タイトル・説明文 | export const metadata / generateMetadata |
| キャッシュ(Next.js 16の新方式) | cacheComponents: true + 'use cache' |
| 更新後にキャッシュを捨てる | revalidatePath / revalidateTag(tag, 'max') / updateTag |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Next.js App Router 入門|v16対応・実際のビルド結果つき」
2026年9月時点のバージョンと、動作確認の環境
この記事は next 16.3.6(npm view next version で確認した最新版)、react 19.2.8、Node.js 22.22.2 で動作を確認しています。Next.js 16 の最低要件は Node.js 20.9 以上です。
Next.js 16(2025年10月)以降の主な変化は次のとおりです。古い記事を読むときは、ここが違う点に注意すると混乱しにくくなります。
| 項目 | 以前 | Next.js 16 以降 |
|---|---|---|
params / searchParams / cookies() / headers() |
同期でも読めた(15では一時的な互換あり) | 必ず await する(同期アクセスは削除) |
middleware.ts |
ミドルウェア | proxy.ts(Proxy)に名称変更(機能は同じ) |
revalidateTag('tag') |
引数1つ | 第2引数('max' など)が必要。1引数は非推奨 |
| キャッシュの新方式 | 実験的な dynamicIO / useCache |
cacheComponents: true と 'use cache' |
next lint / バンドラー |
next lint あり・webpack |
next lint は削除、Turbopack が標準 |
プロジェクトを作る
npx create-next-app@latest nextapp --ts --app --eslint --tailwind --no-src-dir --import-alias "@/*" --use-npm --yes
cd nextapp
npm run dev
--app を付けると App Router のプロジェクトになります(現在は既定でも App Router です)。作成直後の app/ には layout.tsx・page.tsx・globals.css があり、AIコーディングエージェント向けの AGENTS.md も生成されていました。生成された layout.tsx は LayoutProps<"/"> という型を使っていました。Next.js 16 では、ルートの型(PageProps<"/posts/[id]"> など)がビルド時に自動生成され、params の型を手で書かなくてよくなっています。
ファイルベースのルーティング
App Router では「フォルダーが URL の区切り、page.tsx がその URL の画面」です。page.tsx がないフォルダーは URL として公開されないので、部品やユーティリティを同じフォルダーに置いても問題ありません。
特別なファイルの一覧
| ファイル | 役割 | ポイント |
|---|---|---|
page.tsx |
その URL の画面 | これがあるフォルダーだけが公開される |
layout.tsx |
子ページを包む共通の枠 | ページを移動しても状態が保たれ、再描画されない |
loading.tsx |
読み込み中の表示 | page を自動で <Suspense> で包む |
error.tsx |
エラー時の表示 | Client Component 必須('use client') |
not-found.tsx |
404の表示 | notFound() を呼ぶと表示される |
route.ts |
API(Route Handler) | 同じフォルダーに page.tsx と同居できない |
ルートレイアウトとページ
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: { default: "サンプルブログ", template: "%s | サンプルブログ" },
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}
ルートレイアウトには <html> と <body> を必ず書きます。ページ間の移動には <a> ではなく next/link の <Link> を使うと、画面全体を読み直さずに移動でき、表示範囲に入ったリンク先を事前に読み込む(プリフェッチ)こともできます。
loading・error・not-found
// app/posts/error.tsx(loading.tsx は <p>読み込み中…</p> を返すだけ)
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <button onClick={() => reset()}>再試行({error.message})</button>;
}
error.tsx はボタンの onClick などブラウザ側の処理を持つため、'use client' が必要です。not-found.tsx も同じように置くだけで、notFound() を呼んだときに表示されます。なお error.tsx は同じ階層の layout.tsx のエラーは受け止めず、1つ上の階層の error.tsx が担当します。
動的ルート
// app/posts/[id]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost, getPosts } from "@/app/lib/data";
import LikeButton from "@/app/components/LikeButton";
// ビルド時に作っておくURLの一覧
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((p) => ({ id: p.id }));
}
// ページごとのタイトル
export async function generateMetadata({ params }: PageProps<"/posts/[id]">): Promise<Metadata> {
const { id } = await params;
const post = await getPost(id);
return { title: post?.title ?? "見つかりません" };
}
export default async function PostPage({ params }: PageProps<"/posts/[id]">) {
const { id } = await params; // Next.js 16 では必ず await
const post = await getPost(id);
if (!post) notFound(); // not-found.tsx を表示
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton />
</article>
);
}
| フォルダー名 | マッチするURL | params の中身 |
|---|---|---|
[id] |
/posts/1 |
{ id: "1" } |
[...slug] |
/docs/a/b |
{ slug: ["a", "b"] }(1階層以上) |
(marketing) |
URLに含まれない | ルートグループ。レイアウトを分けたいとき |
generateStaticParams で返した URL はビルド時に HTML になり、それ以外の ID は初回アクセス時に作られます(dynamicParams の既定が true のため)。
Server Components と Client Components
App Router では、何も書かなければすべて Server Component です。サーバーで描画され、その部品の JavaScript はブラウザに送られません。ブラウザで動かしたい部品だけ、ファイル先頭に 'use client' を書きます。
| Server Component(既定) | Client Component('use client') |
|
|---|---|---|
| 動く場所 | サーバー(ビルド時またはリクエスト時) | サーバーで初回HTMLを作り、ブラウザで動く |
async / await でデータ取得 |
できる | コンポーネント自体は async にできない |
| DBや秘密のAPIキーへのアクセス | できる | してはいけない(ブラウザに届く) |
useState / useEffect |
使えない | 使える |
onClick などのイベント |
使えない | 使える |
// app/components/LikeButton.tsx
"use client";
import { useState } from "react";
export default function LikeButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>いいね {count}</button>;
}
'use client' は「境界」に1回書く
'use client' は、そのファイルと、そこから import されるファイルをまとめてクライアント側にする「境界線」です。すべてのファイルに書く必要はありません。考え方のコツは次のとおりです。
- ページやレイアウトは Server Component のままにする
- ボタン・フォーム入力・タブなど、操作が必要な末端の部品だけを Client Component に切り出す
- Server Component から Client Component へは props で値を渡せる(関数など、シリアライズできない値は渡せない。Server Actions は例外)
実際に出るビルドエラー(2種類)
'use client' を書き忘れて useState を使うと、ビルドは次のエラーで止まります(LikeButton.tsx の先頭行を消して npm run build した実際の出力の抜粋)。
Error: Turbopack build failed with 1 error:
./app/components/LikeButton.tsx:1:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
Import trace:
Server Component:
./app/components/LikeButton.tsx
./app/posts/[id]/page.tsx
データ取得のファイルの先頭に import "server-only";(npm i server-only)を書いておくと、Client Component から import したときに You're importing a module that depends on "server-only" into a React Client Component module. というエラーでビルドが止まります。DB接続や APIキーを扱うファイルに入れておくと、秘密の情報がブラウザ向けのコードに混ざるのを防げます。
データ取得:async の Server Component で await する
// app/lib/data.ts
import "server-only";
export type Post = { id: string; title: string; body: string };
const posts: Post[] = [
{ id: "1", title: "App Routerを始める", body: "page.tsxを置くとページになります。" },
{ id: "2", title: "Server Components", body: "サーバーで描画されます。" },
];
export async function getPosts(): Promise<Post[]> {
await new Promise((r) => setTimeout(r, 300)); // DBアクセスの代わり
return posts;
}
// getPost(id)・addPost(title, body) も同じファイルに定義
Server Component では、useEffect も API ルートも使わずに、コンポーネントの中で直接 await してデータを取れます。外部 API なら await fetch(...)、DBなら ORM の関数をそのまま呼びます。
互いに依存しないデータが複数あるなら Promise.all で同時に取り、遅いデータは <Suspense> で囲んで後から表示すると、画面の骨格を先に出せます。
キャッシュと再検証:Next.js 16 で2つのモデルがある
Next.js 16 のキャッシュは、「従来のモデル」と、cacheComponents: true で有効になる「Cache Components」の2つがあり、公式ドキュメントもページが分かれています。create-next-app で作った直後は next.config.ts が空なので、従来のモデルで動きます。
| 従来のモデル(既定) | Cache Components(cacheComponents: true) |
|
|---|---|---|
fetch の既定 |
キャッシュしない(15から) | キャッシュしない。必要なら 'use cache' |
| キャッシュの指定 | fetch(url, { cache: 'force-cache' })、next: { revalidate: 3600 }、unstable_cache |
関数やコンポーネントの先頭に 'use cache' + cacheLife |
cookies() などを使ったとき |
ルート全体が動的になる | その部分だけ <Suspense> で後から流す |
| 描画のされ方 | 静的か動的か、ページ単位 | 部分プリレンダー(PPR)が標準 |
従来のモデルでのビルド結果
上のサンプル(/api/hello と、headers() を読む /time を追加)を npm run build した実際の出力です(途中の行は省略)。
▲ Next.js 16.3.6 (Turbopack)
✓ Compiled successfully in 12.8s
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/hello
├ ○ /posts
├ /posts/[id]
│ ├ ● /posts/1
│ └ ● /posts/2
└ ƒ /time
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
| 記号 | 意味 | このアプリでは |
|---|---|---|
| ○ Static | ビルド時にHTMLを作った | /、/posts |
| ● SSG | generateStaticParams でビルド時に作った |
/posts/1、/posts/2 |
| ƒ Dynamic | リクエストのたびにサーバーで作る | headers() を読む /time、/api/hello |
ここで押さえておきたいのは、getPosts() はキャッシュ指定をしていないのに /posts が ○(静的)になっている点です。リクエスト時の情報(cookies()、headers()、searchParams など)を使わないページは、ビルド時にまるごと HTML にされます。つまり 「データ取得がキャッシュされない」ことと「ページが静的に作られる」ことは別の話 で、データを更新したら revalidatePath などでページを作り直す必要があります。
next start で確かめると、/posts の応答には Cache-Control: s-maxage=31536000、/time には private, no-cache, no-store, ... が付いていました。
Cache Components を有効にしたときのビルド結果
next.config.ts に cacheComponents: true を足して同じアプリをビルドすると、/time でビルドが止まりました。
Error: Route "/time": Next.js encountered uncached or runtime data during prerendering.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Cache Components では「リクエスト時の情報は <Suspense> の中で読む」がルールです。次のように書き直すと通ります。
// app/time/page.tsx(Cache Components 用)
import { Suspense } from "react";
import { headers } from "next/headers";
async function UserAgent() {
const ua = (await headers()).get("user-agent") ?? "unknown";
return <p>あなたのブラウザ:{ua}</p>;
}
export default function TimePage() {
return (
<Suspense fallback={<p>読み込み中…</p>}>
<UserAgent />
</Suspense>
);
}
さらに、データ取得の関数に 'use cache' を付けます。
// app/lib/data.ts(Cache Components 用の getPosts)
import { cacheLife, cacheTag } from "next/cache";
export async function getPosts(): Promise<Post[]> {
"use cache";
cacheLife("hours"); // 1時間ごとに再検証
cacheTag("posts"); // あとで updateTag("posts") で捨てられるように
await new Promise((r) => setTimeout(r, 300));
return posts;
}
このときのビルド出力です。
- Cache Components enabled
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/hello
├ ○ /posts 1h 1d
├ /posts/[id]
│ ├ ◐ /posts/[id]
│ ├ ○ /posts/1
│ └ ○ /posts/2
└ ◐ /time
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
/time は ◐(部分プリレンダー)になり、「読み込み中…」はビルド時の HTML に含まれ、ブラウザ情報の部分だけがリクエスト時に流れてきます。/posts には cacheLife("hours") の値(再検証 1h、期限 1d)が表示されました。cacheLife には seconds・minutes・hours・days・weeks・max のプロファイルがあり、たとえば hours は「1時間で再検証、1日で期限切れ」です(公式ドキュメントより)。
再検証の関数の使い分け
| 関数 | 呼べる場所 | 動き | 向いている場面 |
|---|---|---|---|
revalidatePath('/posts') |
Server Actions、Route Handlers | そのパスのキャッシュを捨てる | どのタグか分からないとき |
revalidateTag('posts', 'max') |
Server Actions、Route Handlers | 古い内容を返しつつ裏で作り直す | ブログ・商品一覧など、少し遅れてよいもの。CMSのWebhookから |
updateTag('posts') |
Server Actions のみ | すぐに期限切れにする | 自分が投稿した内容を、直後に自分で見る場面 |
公式ドキュメントでは、パス単位よりタグ単位の再検証がすすめられています(必要な範囲だけを捨てられるため)。
Server Actions:フォーム送信をサーバーの関数で受ける
Server Actions は、'use server' を付けた async 関数を、<form action={...}> に直接渡せる仕組みです。API ルートを作って fetch で POST する、という手順を省けます。
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { addPost } from "@/app/lib/data";
export async function createPost(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (!title) return;
await addPost(title, "本文は未入力です");
revalidatePath("/posts"); // Cache Components なら updateTag("posts")
}
ページ側は、フォームに <form action={createPost}> と渡すだけです(入力欄 name="title" と送信ボタンを置く)。
本番ビルド(next start)をブラウザ(Playwright)で操作して、フォーム送信後に一覧が更新されることを確認しました。従来のモデル(revalidatePath)と Cache Components(updateTag)のどちらでも同じ結果です。
before: [ 'App Routerを始める', 'Server Components' ]
after: [ 'App Routerを始める', 'Server Components', 'Server Actionで追加' ]
いいね 2 ← /posts/1 の Client Component を2回クリックした結果
Server Actions を使うときの注意点です。
- Server Actions は、画面からだけでなく POST リクエストで直接呼べます。関数の中で必ずログイン確認と権限確認をする(公式ドキュメントでも警告されています)
- 入力値は
FormDataから取り出し、サーバー側で検証する(zod などのスキーマ検証ライブラリが便利) - Client Component から呼ぶなら、ファイル先頭に
'use server'を書いた別ファイルにまとめる - 送信中の表示やエラー表示は、React の
useActionState/useFormStatusを使う
Route Handlers:API を作る
// app/api/hello/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const name = searchParams.get("name") ?? "guest";
return Response.json({ message: `hello, ${name}` });
}
$ curl "http://localhost:3456/api/hello?name=taro"
{"message":"hello, taro"}
$ curl -X POST -o /dev/null -w "%{http_code}\n" http://localhost:3456/api/hello
405
GET・POST・PUT・PATCH・DELETE・HEAD・OPTIONS を関数名で export します。定義していないメソッドには、上のとおり自動で 405 Method Not Allowed が返りました。
| 使い分け | Server Actions | Route Handlers |
|---|---|---|
| 呼ぶのは | 自分のアプリのフォームやボタン | 外部サービス、スマホアプリ、Webhook |
| HTTPメソッド | POST のみ | 自由に定義できる |
| 向いている処理 | 画面からの登録・更新・削除 | 公開API、Webhook受信、ファイル配信 |
自分のアプリの画面からの更新は Server Actions、外から呼ばれる入口は Route Handlersと分けると整理しやすくなります。なお、Server Component から自分の Route Handler を fetch する必要はありません。データ取得の関数を直接呼べば済みます。
metadata:タイトルと説明文
固定のタイトルは export const metadata = { title: "記事一覧" }、URL によって変わるタイトルは前述の generateMetadata で返します。ルートレイアウトの title.template("%s | サンプルブログ")と組み合わせた、実際の出力です。
/posts → <title>記事一覧 | サンプルブログ</title>
/posts/1 → <title>App Routerを始める | サンプルブログ</title>
同じ URL への fetch は1回の描画の中で自動的にまとめられるため、generateMetadata とページ本体で同じデータを取っても二重には通信しません(DBを直接呼ぶなら React の cache() で包む)。OGP画像は opengraph-image.tsx、サイトマップは sitemap.ts を置くだけで作れます。
404 のステータスコードに注意(検証で分かったこと)
存在しない /posts/99 にアクセスすると、not-found の画面は表示されましたが、HTTPステータスは 200 でした。
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3456/posts/99
200
$ curl -s http://localhost:3456/posts/99 | grep -o '<meta name="robots"[^>]*>'
<meta name="robots" content="noindex"/>
これは仕様です。app/posts/loading.tsx があるため、応答は Suspense のストリーミングで始まり、先に 200 OK を返しています。その後で notFound() が呼ばれても、ステータスを変更できません。代わりに <meta name="robots" content="noindex"> が自動で入り、検索エンジンに登録されないようになっています(公式ドキュメントの streaming ガイド、loading.js のページに記載)。一方、どこにも一致しない /nothing は 404 でした。
本物の 404 ステータスが必要な場合は、ストリーミングが始まる前(proxy.ts など)で存在確認をします。
Pages Router との違い
| 項目 | Pages Router(pages/) |
App Router(app/) |
|---|---|---|
| ページの定義 | pages/about.tsx |
app/about/page.tsx |
| 共通レイアウト | _app.tsx / _document.tsx |
layout.tsx(入れ子にできる) |
| データ取得 | getServerSideProps / getStaticProps |
async Server Component で await |
| 部品の既定 | すべて Client Component | すべて Server Component |
| API | pages/api/*.ts |
app/**/route.ts |
| フォーム送信 | API ルートへ fetch | Server Actions |
| 読み込み中・エラー | 自分で実装 | loading.tsx / error.tsx |
| ルーターのフック | next/router |
next/navigation(useRouter、usePathname など) |
Pages Router も引き続きサポートされており、pages/ と app/ は同じプロジェクトに共存できるため、移行はページ単位で進められます。新しく作るなら、公式ドキュメントの中心である App Router から始めるのがおすすめです。
よくあるつまずきと対処
| 症状 | 原因 | 対処 |
|---|---|---|
useState のビルドエラー |
Server Component でフックを使った | 操作する部品を切り出して 'use client' |
params.id が undefined や型エラー |
Next.js 16 では params が Promise |
const { id } = await params |
| データを更新しても画面が古い | ページがビルド時に静的生成されている | revalidatePath / updateTag を呼ぶ |
middleware.ts が効かない(16へ更新後) |
名称が Proxy に変わった | proxy.ts にし、関数名を proxy に(codemod あり) |
よくある質問
Q. Cache Components は有効にしたほうがいいですか? A. 新しく作るアプリなら、公式ドキュメントの「Caching」ページが Cache Components を前提に書かれているので、有効にして始めると資料と食い違いにくくなります。既存アプリは、ビルドエラーで指摘されたルートを1つずつ直しながら移行します。
Q. fetch は自動でキャッシュされますか?
A. Next.js 15 以降、fetch は既定でキャッシュされません。ただし従来のモデルでは、リクエスト時の情報を使わないページはビルド時に丸ごと静的化されるため、取得結果が HTML に固定されます(この記事の /posts がその例)。
Q. next dev と next build で動きが違うのはなぜですか?
A. 開発中はページが毎回描画され、キャッシュされないためです(公式ドキュメントに記載)。キャッシュや静的・動的の判定は、npm run build の出力と npm start で確認するのが確実です。
まとめ
- App Router は フォルダーが URL、
page.tsxが画面。layout・loading・error・not-foundを置くだけで役割が決まる - 何も書かなければ Server Component。操作が必要な末端の部品だけ
'use client'にする - データは async の Server Component で
awaitして取る。秘密を扱うファイルにはserver-only - Next.js 16 では
paramsなどは必ずawait、middlewareはproxyに - キャッシュは 従来のモデル と Cache Components(
'use cache') の2つ。どちらかを意識して書く - 更新は Server Actions、外部向けの API は Route Handlers。再検証は
revalidatePath・revalidateTag(tag, 'max')・updateTagを使い分ける npm run buildの ○ ● ◐ ƒ を見ると、各ページがどう配信されるかが分かる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Next.js App Router 入門|v16対応・実際のビルド結果つき」
株式会社RJCでは、Next.js のような進化の速いフレームワークも、公式ドキュメントと実際のビルド結果を確かめながら、仲間と一緒に学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。