Next.js 15 と React 19 の組み合わせは、フロントエンドの「描画戦略」を根本から書き換えました。本記事は、create-next-app から始めて App Router・Server Components・Server Actions・データフェッチ・キャッシュ・デプロイ までを、すべて コピペで動く実装コード で総覧する実践ガイドです。
対象読者は、React の基本(useState / useEffect / props)は書けるが、「"use client" をどこに置く?」「Server Action と API Routes はどちらを選ぶ?」「ISR の revalidate と revalidatePath の違いは?」が曖昧な20〜40代の現役 Web エンジニアです。本記事を通読すれば、Next.js 15 で本番アプリ1本を組み切るだけの実装地図が手に入ります。
- Next.js 15 が前提にしている世界観
- App Router の中核ファイル一式
- ルーティングの応用パターン5種
- Server Component と Client Component の分け方
- データフェッチとキャッシュ4階層
- Server Actions ― API ルート不要のミューテーション
- API Routes(Route Handlers)と Edge Runtime
- Middleware と認証パターン
- 画像・フォント・メタデータの最適化
- Streaming SSR と Partial Prerendering
- 本番デプロイの2大選択肢
- 学習を加速させるためのロードマップ
- まとめ ― Next.js 15 で最初に身につけるべき8つ
Next.js 15 が前提にしている世界観
Next.js 15 では 「サーバーで描く・クライアントで操る」 が標準路線になりました。App Router(app/ ディレクトリ)・React Server Components(RSC)・Server Actions・キャッシュ4階層の4つが組み合わさり、従来の SPA とは違う設計判断が必要になります。
| 従来(Pages Router) | App Router(Next.js 15) |
|---|---|
pages/index.tsx がルート | app/page.tsx がルート |
getServerSideProps / getStaticProps | Server Component の async/await で直 fetch |
_app.tsx / _document.tsx | app/layout.tsx(階層レイアウト) |
pages/api/*.ts | Server Actions または app/api/*/route.ts |
| クライアント主体 SPA | サーバー主体 + 必要箇所のみ "use client" |
React 19 と Next.js 15 のバージョン整合
Next.js 15 は React 19 を前提にしています。useActionState / useOptimistic / use フックなど React 19 の新 API が、Server Actions と組み合わせて動く前提で設計されています。package.json はおおむね次のような構成になります。
{
"name": "my-next15-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "22.10.0",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"typescript": "5.7.0"
}
}
create-next-app でゼロから立ち上げる
最短経路は公式の create-next-app です。TypeScript / ESLint / Tailwind CSS / App Router / src/ ディレクトリの有無を対話で選びます。本記事では TS + App Router + src あり を前提にします。
# npm
npx create-next-app@latest my-next15-app
--typescript
--eslint
--tailwind
--app
--src-dir
--import-alias "@/*"
# pnpm
pnpm create next-app my-next15-app --typescript --app --src-dir
cd my-next15-app
pnpm dev # http://localhost:3000
標準的なプロジェクト構造
初期生成されるディレクトリは小さいですが、実務では機能別にファイルを分けていきます。本記事で扱う構成のフル形は次の通りです。
my-next15-app/
├─ src/
│ ├─ app/
│ │ ├─ layout.tsx # ルートレイアウト(必須)
│ │ ├─ page.tsx # / のページ
│ │ ├─ loading.tsx # ローディング UI
│ │ ├─ error.tsx # エラーバウンダリ
│ │ ├─ not-found.tsx # 404
│ │ ├─ globals.css
│ │ ├─ (marketing)/ # route group
│ │ │ └─ about/page.tsx
│ │ ├─ blog/
│ │ │ ├─ page.tsx # /blog
│ │ │ ├─ [slug]/page.tsx # /blog/:slug
│ │ │ └─ [...slug]/page.tsx
│ │ ├─ @modal/ # parallel route
│ │ │ └─ default.tsx
│ │ └─ api/
│ │ └─ hello/route.ts # /api/hello
│ ├─ components/
│ ├─ lib/
│ └─ middleware.ts
├─ public/
├─ next.config.ts
├─ tsconfig.json
└─ package.json
App Router の中核ファイル一式
App Router では、ディレクトリ名がそのまま URL になり、その配下に 特別な役割を持つファイル(page.tsx / layout.tsx / loading.tsx / error.tsx / not-found.tsx)を置く規約です。これらは「予約ファイル名」と呼ばれ、それぞれが React コンポーネントとして自動で組み立てられます。
layout.tsx ― 全ページの土台
app/layout.tsx は 必須のルートレイアウトです。<html> と <body> を返す必要があり、ここに置いた要素は全ページで共通になります。子ルートにも個別に layout.tsx を置けば、その配下だけに適用されるネストレイアウトになります。
// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "My Next 15 App",
template: "%s | My Next 15 App",
},
description: "Next.js 15 + React 19 のサンプル",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<header>
<nav>グローバルナビ</nav>
</header>
<main>{children}</main>
<footer>© 2026 My App</footer>
</body>
</html>
);
}
page.tsx ― URL を持つ唯一のファイル
URL を生成するのは page.tsx だけです。layout.tsx や loading.tsx は URL を作りません。デフォルトでは Server Component なので、そのまま async 関数として書けるのが App Router の特徴です。
// src/app/page.tsx
export default async function HomePage() {
// サーバーで実行される。クライアントには JS は送られない
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 }, // 60秒 ISR
});
const posts: { id: string; title: string }[] = await res.json();
return (
<section>
<h1>最新記事</h1>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</section>
);
}
loading.tsx ― 同階層の Suspense フォールバック
loading.tsx は、同じディレクトリの page.tsx が async でデータ取得中に出すローディング UI です。自動で Suspense でラップされるため、自分で <Suspense> を書かなくても Streaming SSR が効きます。
// src/app/loading.tsx
export default function Loading() {
return (
<div role="status" aria-live="polite">
<p>読み込み中...</p>
</div>
);
}
error.tsx ― Error Boundary を宣言的に
error.tsx は React の Error Boundary を Next.js が自動でラップしたものです。必ず Client Component("use client")で、reset 関数を受け取って再試行できます。
// src/app/error.tsx
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>エラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>再試行</button>
</div>
);
}
not-found.tsx ― 404 ページ
notFound() を呼ぶか、URL が一致しないときに自動で表示されます。グローバル 404 はルートに、ネスト 404 は各サブツリーに置けます。
// src/app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h2>404 - ページが見つかりません</h2>
<Link href="/">トップへ戻る</Link>
</div>
);
}
ルーティングの応用パターン5種
App Router は単純なパスマッピングだけでなく、route group・dynamic route・catch-all・parallel route・intercepting route の5種類でほぼあらゆる URL 設計に対応できます。それぞれの典型コードを順に見ます。
1. route group ― URL に出ないディレクトリ
括弧 () で囲んだディレクトリは URL セグメントにならない論理グループです。「マーケティング系」「ダッシュボード系」のように layout.tsx を分けたいが URL は分けたくない場合に使います。
// src/app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="bg-white">
<header>マーケティング用ヘッダー</header>
{children}
</div>
);
}
// src/app/(marketing)/about/page.tsx
// URL は /about (marketing は URL に出ない)
export default function AboutPage() {
return <h1>会社概要</h1>;
}
2. dynamic route ― [id]
角括弧で囲んだディレクトリ名は動的セグメントになります。Next.js 15 から params は Promise になったため、await が必須です。
// src/app/blog/[slug]/page.tsx
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [k: string]: string | string[] | undefined }>;
};
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params; // Next.js 15: params は Promise
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
r.json()
);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
3. catch-all route ― […slug]
[...slug] はそれ以下のすべてのパスを配列で受け取ります。[[...slug]] のように二重括弧にするとオプショナル(空のときも一致)になります。
// src/app/docs/[...slug]/page.tsx
type Props = { params: Promise<{ slug: string[] }> };
export default async function DocsPage({ params }: Props) {
const { slug } = await params; // ["guide", "intro"] のような配列
const path = slug.join("/"); // "guide/intro"
return <h1>ドキュメント: /{path}</h1>;
}
4. parallel route ― @slot
@ プレフィックスのディレクトリは 同一 URL で複数の page.tsx を並列描画するためのスロットです。ダッシュボードの「メイン」「サイドパネル」「モーダル」を独立して描画するのに向いています。
// src/app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode; // @modal スロット
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
// src/app/@modal/default.tsx
// スロットが一致しないときの既定値
export default function ModalDefault() {
return null;
}
// src/app/@modal/login/page.tsx
// /login にアクセスしたとき @modal にもログインフォームを表示
export default function LoginModal() {
return <dialog open>ログインフォーム</dialog>;
}
5. intercepting route ― (.)
(.) / (..) / (...) は別ルートのページを「現在のレイアウト内に差し込む」インターセプトです。Instagram の写真モーダルのように 遷移時はモーダル / リロード時は単独ページといった UX に必須です。
// src/app/photos/[id]/page.tsx (単独遷移時)
export default async function PhotoPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <img src={`/api/photos/${id}`} alt="" />;
}
// src/app/@modal/(.)photos/[id]/page.tsx (一覧からの遷移時にモーダル化)
"use client";
import { useRouter } from "next/navigation";
export default function PhotoModal({ params }: { params: { id: string } }) {
const router = useRouter();
return (
<dialog open onClose={() => router.back()}>
<img src={`/api/photos/${params.id}`} alt="" />
</dialog>
);
}
Server Component と Client Component の分け方
App Router の すべてのコンポーネントはデフォルトで Server Component です。useState / useEffect / onClick / window など クライアント API を使う瞬間にだけ "use client" を付けるのが原則です。
Server Component の典型形
// src/components/UserList.tsx
// "use client" を書かない = Server Component
import { db } from "@/lib/db";
export async function UserList() {
// サーバーで直接 DB にアクセスできる
const users = await db.user.findMany({ orderBy: { createdAt: "desc" } });
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Client Component の典型形
ファイル先頭の "use client" は、そのファイルから import される全コンポーネントを「クライアント境界より下」に置く宣言です。Server Component を import することはできなくなります。
// src/components/Counter.tsx
"use client";
import { useState } from "react";
export function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<button onClick={() => setCount((c) => c + 1)}>
count: {count}
</button>
);
}
境界設計の鉄則 ― 「クライアント境界はできるだけ葉に」
よくあるアンチパターンは、ページ全体に "use client" を付けてしまうことです。これでは Server Component の恩恵がすべて失われます。インタラクションが必要な部分だけを小さな Client Component に切り出し、それ以外は Server Component のままにします。
// ✅ 良い例: ページは Server、ボタンだけ Client
// src/app/dashboard/page.tsx
import { Counter } from "@/components/Counter"; // Client
import { UserList } from "@/components/UserList"; // Server
export default async function DashboardPage() {
return (
<section>
<UserList /> {/* サーバーで描画 */}
<Counter initial={0} /> {/* この部分だけハイドレーション */}
</section>
);
}
データフェッチとキャッシュ4階層
Next.js 15 のデータフェッチは 「拡張された fetch」が中心です。React 19 の cache() も組み合わさり、要件に応じて4段階のキャッシュ戦略を選べます。
| 戦略 | 指定方法 | 用途 |
|---|---|---|
| Static (SSG) | fetch(url)(既定/force-cache) | 更新頻度の低い記事 |
| ISR | fetch(url, { next: { revalidate: 60 } }) | EC 商品一覧 |
| Dynamic | fetch(url, { cache: "no-store" }) | マイページ・カート |
| On-demand | revalidatePath() / revalidateTag() | CMS 公開直後の即反映 |
Static Generation ― 既定のキャッシュ
// src/app/articles/page.tsx
export default async function ArticlesPage() {
// 既定で force-cache。ビルド時に1回だけ取得して固定
const res = await fetch("https://api.example.com/articles");
const list = await res.json();
return (
<ul>
{list.map((a: { id: string; title: string }) => (
<li key={a.id}>{a.title}</li>
))}
</ul>
);
}
ISR ― revalidate で秒数指定
// src/app/products/page.tsx
export const revalidate = 300; // ページ全体の再生成間隔(秒)
export default async function ProductsPage() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60, tags: ["products"] },
});
const products = await res.json();
return <pre>{JSON.stringify(products, null, 2)}</pre>;
}
Dynamic Rendering ― no-store
// src/app/mypage/page.tsx
import { cookies } from "next/headers";
export default async function MyPage() {
const c = await cookies();
const token = c.get("session")?.value;
// no-store でリクエストごとにフェッチ
const me = await fetch("https://api.example.com/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${token ?? ""}` },
}).then((r) => r.json());
return <h1>こんにちは {me.name} さん</h1>;
}
On-demand Revalidation ― revalidatePath / revalidateTag
CMS で記事を公開した瞬間にキャッシュを破棄したい場合は、Server Action や Route Handler から revalidatePath / revalidateTag を呼びます。tag は fetch オプションの next.tags と対応します。
// src/app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { path, tag, secret } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 });
}
if (path) revalidatePath(path);
if (tag) revalidateTag(tag);
return NextResponse.json({ ok: true });
}
generateStaticParams ― 動的ルートを SSG
動的ルートでもビルド時に静的化できます。generateStaticParams が返した分だけ HTML が事前生成されます。
// src/app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}
// 一覧外の slug にアクセスされた場合の挙動
export const dynamicParams = true; // false にすると 404
Server Actions ― API ルート不要のミューテーション
Server Actions は 関数を「サーバー上で実行する RPC」として直接呼べる仕組みです。"use server" ディレクティブを付けた関数は <form action={fn}> や Client Component からの呼び出しでネットワーク越しに実行されます。API Routes を自前で書く回数が劇的に減ります。
最小形 ― フォームから直接呼ぶ
// src/app/contact/page.tsx
import { redirect } from "next/navigation";
async function submitContact(formData: FormData) {
"use server"; // ファイル全体ではなく関数だけ server にする
const name = formData.get("name")?.toString() ?? "";
const message = formData.get("message")?.toString() ?? "";
// ここはサーバー上で実行される
await fetch("https://hooks.slack.com/services/xxx", {
method: "POST",
body: JSON.stringify({ text: `${name}: ${message}` }),
});
redirect("/contact/thanks");
}
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" required />
<textarea name="message" required />
<button type="submit">送信</button>
</form>
);
}
ファイル全体を Server Actions モジュールに
複数のアクションをまとめたいときは、ファイル冒頭で "use server" を宣言します。Client Component から import するときの典型形です。
// src/app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(1).max(120),
body: z.string().min(1),
});
export async function createPost(formData: FormData) {
const parsed = PostSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten() } as const;
}
const post = await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { ok: true, post } as const;
}
useActionState で pending と結果を扱う
React 19 の useActionState(旧 useFormState)は、Server Action の戻り値とローディング状態を Client Component で受け取るためのフックです。
// src/app/posts/PostForm.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "./actions";
type State = { ok: boolean; errors?: Record<string, string[]> };
const initial: State = { ok: false };
export function PostForm() {
const [state, action, pending] = useActionState(
async (_prev: State, formData: FormData) => {
const r = await createPost(formData);
return r as State;
},
initial
);
return (
<form action={action}>
<input name="title" required />
<textarea name="body" required />
<button type="submit" disabled={pending}>
{pending ? "送信中..." : "投稿"}
</button>
{state.errors && <p role="alert">入力エラー</p>}
</form>
);
}
useOptimistic で楽観的 UI
// src/app/todos/TodoList.tsx
"use client";
import { useOptimistic } from "react";
import { addTodo } from "./actions";
type Todo = { id: string; text: string; sending?: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimistic, addOptimistic] = useOptimistic(
todos,
(prev, next: Todo) => [...prev, { ...next, sending: true }]
);
return (
<>
<ul>
{optimistic.map((t) => (
<li key={t.id} style={{ opacity: t.sending ? 0.5 : 1 }}>
{t.text}
</li>
))}
</ul>
<form
action={async (fd) => {
const text = fd.get("text") as string;
addOptimistic({ id: crypto.randomUUID(), text });
await addTodo(text);
}}
>
<input name="text" />
<button>追加</button>
</form>
</>
);
}
API Routes(Route Handlers)と Edge Runtime
Server Actions で多くは置き換わりましたが、Webhook 受信・外部からの REST API・OAuth コールバックなどは従来通り API Routes が向いています。App Router では app/api/*/route.ts という規約です。
GET / POST を1ファイルで書く
// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const limit = Number(url.searchParams.get("limit") ?? 20);
const posts = await db.post.findMany({ take: limit });
return NextResponse.json(posts);
}
export async function POST(req: NextRequest) {
const body = (await req.json()) as { title: string; body: string };
if (!body.title) {
return NextResponse.json({ error: "title required" }, { status: 400 });
}
const created = await db.post.create({ data: body });
return NextResponse.json(created, { status: 201 });
}
動的セグメントを持つ API Route
// src/app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return NextResponse.json({ id });
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return NextResponse.json({ deleted: id });
}
Edge Runtime で世界中に近い CDN 実行
route.ts や page.tsx のファイル末尾で export const runtime = "edge" を指定すると、Vercel 等の Edge ロケーションで実行されます。起動が速くレイテンシが低い反面、Node 固有 API(fs・net・一部の npm パッケージ)は使えません。
// src/app/api/geo/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
export async function GET(req: NextRequest) {
const country = req.geo?.country ?? "JP";
return NextResponse.json({ country });
}
cookies / headers のサーバー API
// 任意の Server Component / Route Handler / Server Action から
import { cookies, headers } from "next/headers";
export async function readContext() {
const c = await cookies(); // Next.js 15: cookies() は async
const h = await headers();
return {
session: c.get("session")?.value,
userAgent: h.get("user-agent"),
};
}
Middleware と認証パターン
middleware.ts はリクエストが各ルートに届く前に動く Edge 関数です。認証チェック・i18n リダイレクト・A/B テスト・レスポンスヘッダ追加などに使います。
// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(req: NextRequest) {
const isAuthed = req.cookies.get("session")?.value;
const { pathname } = req.nextUrl;
if (pathname.startsWith("/dashboard") && !isAuthed) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("redirect", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};
Server Action 内で認可チェック
Middleware はパス単位の粗いガードです。個別の書き込み操作は Server Action 内でも認可チェックするのが安全です。
// src/app/admin/actions.ts
"use server";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
export async function deletePost(id: string) {
const session = (await cookies()).get("session")?.value;
const user = session ? await db.user.findUnique({ where: { token: session } }) : null;
if (!user || user.role !== "admin") {
throw new Error("Forbidden");
}
await db.post.delete({ where: { id } });
}
画像・フォント・メタデータの最適化
next/image ― 画像最適化の標準解
<Image> は WebP/AVIF 自動変換・遅延ロード・LCP 自動優先化を備えた最適化済み画像コンポーネントです。fill か width/height 指定のどちらかが必要です。
// src/app/page.tsx
import Image from "next/image";
import hero from "@/public/hero.jpg"; // 静的 import で width/height 自動
export default function Home() {
return (
<>
{/* 静的画像: width/height はビルド時に計算される */}
<Image src={hero} alt="hero" priority placeholder="blur" />
{/* 外部画像: width/height 必須 */}
<Image
src="https://images.example.com/photo.jpg"
alt=""
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 1200px"
/>
{/* 親要素を埋める fill モード */}
<div style={{ position: "relative", aspectRatio: "16/9" }}>
<Image src="/banner.jpg" alt="" fill style={{ objectFit: "cover" }} />
</div>
</>
);
}
外部画像ドメインの許可
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.example.com" },
{ protocol: "https", hostname: "**.amazonaws.com" },
],
},
};
export default config;
next/font で CLS ゼロのフォント読込
// src/app/layout.tsx
import { Inter, Noto_Sans_JP } from "next/font/google";
const inter = Inter({ subsets: ["latin"], display: "swap", variable: "--font-inter" });
const noto = Noto_Sans_JP({
weight: ["400", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-noto",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" className={`${inter.variable} ${noto.variable}`}>
<body style={{ fontFamily: "var(--font-noto), var(--font-inter), sans-serif" }}>
{children}
</body>
</html>
);
}
Metadata API で SEO/OGP を宣言的に
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json());
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.ogImage, width: 1200, height: 630 }],
type: "article",
},
twitter: { card: "summary_large_image" },
alternates: { canonical: `https://example.com/blog/${slug}` },
};
}
Streaming SSR と Partial Prerendering
App Router の真価は、1ページの中で「静的部分」と「動的部分」を別々のタイミングで返せることにあります。<Suspense> でラップした部分は HTML がストリームで遅れて届きます。
Suspense で部分ローディング
// src/app/dashboard/page.tsx
import { Suspense } from "react";
async function SlowStats() {
const r = await fetch("https://api.example.com/stats", { cache: "no-store" });
const stats = await r.json();
return <dl>{Object.entries(stats).map(([k, v]) => <div key={k}>{k}: {String(v)}</div>)}</dl>;
}
async function FastNews() {
const r = await fetch("https://api.example.com/news", { next: { revalidate: 60 } });
return <ul>{(await r.json()).map((n: { id: string; title: string }) => <li key={n.id}>{n.title}</li>)}</ul>;
}
export default function Dashboard() {
return (
<>
<h1>Dashboard</h1>
<FastNews />
<Suspense fallback={<p>統計取得中...</p>}>
<SlowStats />
</Suspense>
</>
);
}
Partial Prerendering(PPR)を有効化
PPR は 「静的な殻を即座に返し、動的な穴を後から流し込む」新しいレンダリングモードです。Next.js 15 では experimental から段階的に安定化中で、next.config.ts でオプトインします。
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: "incremental", // 段階導入。"true" にすると全ルート対象
},
};
export default config;
// src/app/products/page.tsx
export const experimental_ppr = true; // ファイル単位で有効化
本番デプロイの2大選択肢
Vercel ― ゼロ設定デプロイ
Next.js を作っているのが Vercel なので、まずは Vercel が最短です。GitHub リポジトリを接続するだけで、ISR・Edge Runtime・PPR まですべて動きます。CLI からも数秒です。
# 初回ログイン
npx vercel login
# プロジェクトをリンクして production デプロイ
npx vercel --prod
# 環境変数を CLI から登録
npx vercel env add DATABASE_URL production
自前サーバー / VPS で動かす
next build + next start で Node 上の長時間プロセスとして動かせます。Docker 化すれば AWS ECS / Cloud Run / Fly.io などどこでも動きます。
# Dockerfile(マルチステージビルド)
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3000
CMD ["npm", "start"]
standalone 出力でイメージを小さく
// next.config.ts
const config = {
output: "standalone", // .next/standalone に必要最小限の依存だけまとめる
};
export default config;
# Dockerfile 末尾を以下に差し替えるとイメージサイズが激減
# COPY --from=build /app/.next/standalone ./
# COPY --from=build /app/.next/static ./.next/static
# COPY --from=build /app/public ./public
# CMD ["node", "server.js"]
学習を加速させるためのロードマップ
ここまでの内容は Next.js 15 の実装地図です。地図を手にしたあとは「自分でアプリを組む実戦」と「コードレビューしてもらえる環境」の両輪が必要です。独学で詰まる典型ポイントは、RSC とクライアント境界の設計・キャッシュと再生成の使い分け・本番運用時のエラー監視の3つで、ここはチーム開発か体系的な学習で潰すのが結局速いです。
独学+実務志向で進める場合
未経験から実務で使えるレベルまで一気に持っていきたい場合は、Next.js を含むモダンフロント講座を持つスクールを検討するのが効率的です。は完全オンライン・現役エンジニアのマンツーマンメンタリングで、Next.js 含む実案件志向の課題が組まれています。学習継続率を担保するため、独学で挫折経験のある人ほど向いています。
転職を見据えてポートフォリオを作りたい場合
本記事のような Next.js 15 のスタックは、フロントエンド/フルスタックの転職市場で評価されやすい構成です。は学習内容をマンツーマンで設計するため、「Next.js + Server Actions + 認証 + 決済」のような実案件サイズのポートフォリオを完走しやすい設計です。卒業後の転職サポートまで一気通貫で組みたい人向きです。
未経験から正社員エンジニア転職を最優先したい場合
20代で正社員転職をゴールに置くなら、のように転職保証付きの短期集中型が選択肢です。Next.js を含むモダンスタックで「動くもの」を仕上げてから面接に臨めるため、独学組とは別レイヤーの選考に乗りやすくなります。
すでに実務経験があり次のキャリアを探す場合
Next.js 15 / React 19 を扱える現役エンジニアは、フリーランス・高単価転職どちらでも市場価値が高い側です。は React/Next.js 案件の取扱量が国内最大級で、登録すると「今の市場で自分のスキルがいくら付くか」の体感値が一気に上がります。転職を急がない人ほど、年1回のキャリア棚卸し用に登録しておく価値があります。
まとめ ― Next.js 15 で最初に身につけるべき8つ
本記事で扱った内容を、最後にチェックリスト形式で振り返ります。順番にコードを写経して動かしておくと、実務初日からの立ち上がりが大きく変わります。
create-next-app+ App Router +src/の標準構成layout.tsx/page.tsx/loading.tsx/error.tsx/not-found.tsxの役割- route group / dynamic / catch-all / parallel / intercepting の5ルーティング
- Server Component を既定とし、葉だけ
"use client"にする境界設計 fetchの4キャッシュ戦略(Static / ISR / Dynamic / On-demand)- Server Actions +
useActionState+useOptimisticのミューテーション next/image/next/font/ Metadata API による最適化- Vercel もしくは Docker(
output: "standalone")での本番デプロイ
Next.js 15 は「学べば学ぶほど書くコードが減る」フレームワークです。本記事のコードはすべてコピペで動く前提で書いてあるので、まずは小さなブログかタスク管理アプリを1本通しで作り切ってみてください。Server Component と Server Actions を当たり前に書ける状態になった頃には、現場の Next.js 案件で即戦力扱いされる位置に立っています。

コメント