この記事の要点
• App Routerでファイルベースルーティング(page.tsx/layout.tsx)
• Server Componentsで直接async/await、Server Actionsでフォーム処理
• "use client"宣言でクライアントコンポーネントを明示
プロジェクト構造
Next.js 14+ の App Router 標準構造(公式: https://nextjs.org/docs/app/getting-started/project-structure):
app/
├── layout.tsx # ルートレイアウト
├── page.tsx # ホームページ (/)
├── loading.tsx # ローディングUI
├── error.tsx # エラーUI
├── not-found.tsx # 404ページ
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
├── api/
│ └── users/
│ └── route.ts # /api/users
└── (marketing)/ # ルートグループ
└── about/
└── page.tsx # /about
ページとレイアウト
ページ
// app/page.tsx
export default function Home() {
return <h1>ホーム</h1>;
}
// app/blog/[slug]/page.tsx
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export default function BlogPost({ params, searchParams }: PageProps) {
return <h1>{params.slug}</h1>;
}
レイアウト
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// ネストしたレイアウト
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
ポイント: layout.tsxは子ページ間で共有されるUI。ヘッダー・サイドバーなど共通パーツはlayoutに配置し、ページ固有のコンテンツはpage.tsxに書きます。
データフェッチ
Server Components
// 直接async/await
async function getData() {
const res = await fetch("https://api.example.com/data");
return res.json();
}
export default async function Page() {
const data = await getData();
return <div>{data.title}</div>;
}
// キャッシュ制御
const data = await fetch("https://api.example.com/data", {
cache: "no-store", // キャッシュなし
});
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 60 }, // 60秒ごとに再検証
});
並列フェッチ
export default async function Page() {
const [user, posts] = await Promise.all([
getUser(),
getPosts(),
]);
return (
<>
<UserProfile user={user} />
<PostList posts={posts} />
</>
);
}
Suspense
import { Suspense } from "react";
export default function Page() {
return (
<div>
<h1>ダッシュボード</h1>
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
</div>
);
}
実践メモ: 複数のAPIを呼ぶ場合はPromise.allで並列フェッチ。直列に書くとウォーターフォールが発生し、表示が遅くなります。
注意: cache: "no-store"とnext: { revalidate: N }を同時に指定しないように。キャッシュ戦略はページの特性に応じて1つ選びましょう。
Server Actions
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.post.create({ data: { title, content } });
revalidatePath("/posts");
}
// コンポーネントで使用
export default function NewPostForm() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">投稿</button>
</form>
);
}
useActionState
"use client";
import { useActionState } from "react";
import { createPost } from "./actions";
export default function Form() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" disabled={isPending} />
<button disabled={isPending}>
{isPending ? "送信中..." : "投稿"}
</button>
{state?.error && <p>{state.error}</p>}
</form>
);
}
ポイント: "use server"を付けた関数はサーバーでのみ実行されます。revalidatePathでキャッシュを無効化し、最新データを表示しましょう。
API Routes
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = searchParams.get("page");
const users = await db.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(user);
}
ナビゲーション
Link
import Link from "next/link";
<Link href="/about">About</Link>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
<Link href="/dashboard" prefetch={false}>Dashboard</Link>
useRouter
"use client";
import { useRouter } from "next/navigation";
export default function Component() {
const router = useRouter();
return (
<button onClick={() => router.push("/dashboard")}>
ダッシュボードへ
</button>
);
}
// メソッド
router.push("/path"); // 遷移
router.replace("/path"); // 履歴を置換
router.back(); // 戻る
router.forward(); // 進む
router.refresh(); // 再フェッチ
usePathname / useSearchParams
"use client";
import { usePathname, useSearchParams } from "next/navigation";
export default function Component() {
const pathname = usePathname(); // "/blog/hello"
const searchParams = useSearchParams();
const page = searchParams.get("page"); // "1"
return <div>{pathname}</div>;
}
メタデータ
// 静的メタデータ
export const metadata = {
title: "ページタイトル",
description: "ページの説明",
};
// 動的メタデータ
export async function generateMetadata({ params }: PageProps) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.image],
},
};
}
静的生成
// 静的パスの生成
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function Page({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}
Middleware
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// 認証チェック
const token = request.cookies.get("token");
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
注意: NEXT_PUBLIC_プレフィックスのない環境変数はクライアントに公開されません。ブラウザ側で使う値には必ずNEXT_PUBLIC_を付けましょう。逆に、秘密情報にはこのプレフィックスを付けないように。
実践メモ: useRouterはClient Componentでのみ使用可能。Server Componentからのリダイレクトはredirect()関数を使います。
環境変数
# .env.local
DATABASE_URL=postgresql://...
NEXT_PUBLIC_API_URL=https://api.example.com
// サーバーサイドのみ
const dbUrl = process.env.DATABASE_URL;
// クライアントでも使用可能(NEXT_PUBLIC_プレフィックス)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// 画像ドメイン許可
images: {
domains: ["example.com"],
},
// リダイレクト
async redirects() {
return [
{ source: "/old", destination: "/new", permanent: true },
];
},
// 環境変数
env: {
customKey: "value",
},
};
module.exports = nextConfig;
参考リソース
- Next.js Documentation - Next.js 公式ドキュメント
- App Router Reference - App Router 公式リファレンス
- Next.js API Reference - APIリファレンス
- Learn Next.js - 公式チュートリアル
注: Next.js 15 以降では
paramsとsearchParamsがPromiseとして渡されます。最新版の API は公式ドキュメントを確認してください。
関連記事
- Next.js Middleware実践 - Middleware詳細
- React Server Components - RSC
- Reactチートシート - React基礎