Next.js 15完全実践ガイド〜App Router・RSC・Server Actions・データフェッチ〜【2026年版】

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 の revalidaterevalidatePath の違いは?」が曖昧な20〜40代の現役 Web エンジニアです。本記事を通読すれば、Next.js 15 で本番アプリ1本を組み切るだけの実装地図が手に入ります。

  1. Next.js 15 が前提にしている世界観
    1. React 19 と Next.js 15 のバージョン整合
    2. create-next-app でゼロから立ち上げる
    3. 標準的なプロジェクト構造
  2. App Router の中核ファイル一式
    1. layout.tsx ― 全ページの土台
    2. page.tsx ― URL を持つ唯一のファイル
    3. loading.tsx ― 同階層の Suspense フォールバック
    4. error.tsx ― Error Boundary を宣言的に
    5. not-found.tsx ― 404 ページ
  3. ルーティングの応用パターン5種
    1. 1. route group ― URL に出ないディレクトリ
    2. 2. dynamic route ― [id]
    3. 3. catch-all route ― […slug]
    4. 4. parallel route ― @slot
    5. 5. intercepting route ― (.)
  4. Server Component と Client Component の分け方
    1. Server Component の典型形
    2. Client Component の典型形
    3. 境界設計の鉄則 ― 「クライアント境界はできるだけ葉に」
  5. データフェッチとキャッシュ4階層
    1. Static Generation ― 既定のキャッシュ
    2. ISR ― revalidate で秒数指定
    3. Dynamic Rendering ― no-store
    4. On-demand Revalidation ― revalidatePath / revalidateTag
    5. generateStaticParams ― 動的ルートを SSG
  6. Server Actions ― API ルート不要のミューテーション
    1. 最小形 ― フォームから直接呼ぶ
    2. ファイル全体を Server Actions モジュールに
    3. useActionState で pending と結果を扱う
    4. useOptimistic で楽観的 UI
  7. API Routes(Route Handlers)と Edge Runtime
    1. GET / POST を1ファイルで書く
    2. 動的セグメントを持つ API Route
    3. Edge Runtime で世界中に近い CDN 実行
    4. cookies / headers のサーバー API
  8. Middleware と認証パターン
    1. Server Action 内で認可チェック
  9. 画像・フォント・メタデータの最適化
    1. next/image ― 画像最適化の標準解
    2. 外部画像ドメインの許可
    3. next/font で CLS ゼロのフォント読込
    4. Metadata API で SEO/OGP を宣言的に
  10. Streaming SSR と Partial Prerendering
    1. Suspense で部分ローディング
    2. Partial Prerendering(PPR)を有効化
  11. 本番デプロイの2大選択肢
    1. Vercel ― ゼロ設定デプロイ
    2. 自前サーバー / VPS で動かす
    3. standalone 出力でイメージを小さく
  12. 学習を加速させるためのロードマップ
    1. 独学+実務志向で進める場合
    2. 転職を見据えてポートフォリオを作りたい場合
    3. 未経験から正社員エンジニア転職を最優先したい場合
    4. すでに実務経験があり次のキャリアを探す場合
  13. まとめ ― 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 / getStaticPropsServer Component の async/await で直 fetch
_app.tsx / _document.tsxapp/layout.tsx(階層レイアウト)
pages/api/*.tsServer 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.tsxloading.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.tsxasync でデータ取得中に出すローディング 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)更新頻度の低い記事
ISRfetch(url, { next: { revalidate: 60 } })EC 商品一覧
Dynamicfetch(url, { cache: "no-store" })マイページ・カート
On-demandrevalidatePath() / 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(fsnet・一部の 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 自動優先化を備えた最適化済み画像コンポーネントです。fillwidth/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 startNode 上の長時間プロセスとして動かせます。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 案件で即戦力扱いされる位置に立っています。

コメント

タイトルとURLをコピーしました