こんにちは、かつコーチです。
前回はディレクトリ構成のベストプラクティスを解説しました。
今回はいよいよNext.jsシリーズの最終回です。
これまで学んできたApp Router、Server Components、Client Components、Server Actions、ディレクトリ設計を全部組み合わせて、シンプルなブログアプリを実際に作っていきます。
作るのは「記事一覧」「記事詳細」「投稿フォーム」の3画面を持つ、最小構成のブログアプリです。
この記事で作るものの全体像
今回作るブログアプリは、次のような構成にします。
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx # 記事一覧ページ
│ ├── posts/
│ │ ├── new/
│ │ │ └── page.tsx # 投稿フォームページ
│ │ └── [id]/
│ │ └── page.tsx # 記事詳細ページ
├── lib/
│ └── db.ts # データアクセス層(今回はインメモリで簡易実装)
├── server/
│ └── actions/
│ └── posts.ts # Server Actions
└── components/
├── PostList.tsx
└── PostForm.tsx
前回解説したディレクトリ構成のベストプラクティスに沿って、Server Actionsはserver/actionsにまとめています。
データベースの用意までは今回のスコープ外なので、lib/db.tsにはインメモリの簡易データストアを実装します。
実務では、この部分をPrismaやDrizzleなどのORMに差し替える想定です。
実装手順
ステップ1: データアクセス層を用意する
まずは記事データを扱う簡易的なデータストアを作ります。
// src/lib/db.ts
export type Post = {
id: string;
title: string;
body: string;
createdAt: string;
};
// 本来はDBを使いますが、今回は学習用にメモリ上で保持します
const posts: Post[] = [
{
id: "1",
title: "Next.jsシリーズを始めます",
body: "App Routerを中心に、基礎から実践まで扱っていきます。",
createdAt: "2026-01-10",
},
{
id: "2",
title: "Server Actionsが便利すぎる",
body: "APIルートを別途書かなくてもフォーム送信を扱えるのが強みです。",
createdAt: "2026-03-22",
},
];
export async function getPosts(): Promise<Post[]> {
return [...posts].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
}
export async function getPostById(id: string): Promise<Post | undefined> {
return posts.find((post) => post.id === id);
}
export async function createPost(input: {
title: string;
body: string;
}): Promise<Post> {
const newPost: Post = {
id: String(posts.length + 1),
title: input.title,
body: input.body,
createdAt: new Date().toISOString().slice(0, 10),
};
posts.push(newPost);
return newPost;
}
ステップ2: Server Actionsを定義する
投稿フォームから呼び出すServer Actionを、server/actions/posts.tsに実装します。
// src/server/actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createPost } from "@/lib/db";
export async function createPostAction(formData: FormData) {
const title = formData.get("title");
const body = formData.get("body");
if (typeof title !== "string" || title.trim() === "") {
throw new Error("タイトルは必須です");
}
if (typeof body !== "string" || body.trim() === "") {
throw new Error("本文は必須です");
}
const post = await createPost({ title, body });
// 一覧ページのキャッシュを無効化して最新の投稿を反映させる
revalidatePath("/");
redirect(`/posts/${post.id}`);
}
"use server"ディレクティブを付けると、この関数はサーバー上でのみ実行される関数として扱われます。
フォームのaction属性にこの関数を渡すだけで、クライアント側からサーバー側の処理を直接呼び出せるのがServer Actionsの強みです。
APIルートを別途用意する必要がありません。
ステップ3: 記事一覧ページ(Server Component)
一覧ページは、データ取得をServer Component側で完結させます。
// src/app/page.tsx
import Link from "next/link";
import { getPosts } from "@/lib/db";
export default async function HomePage() {
const posts = await getPosts();
return (
<main>
<h1>ブログ一覧</h1>
<Link href="/posts/new">新しい記事を書く</Link>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
<p>{post.createdAt}</p>
</li>
))}
</ul>
</main>
);
}
Client ComponentのようにuseEffectでデータを取りに行く必要がなく、awaitだけでデータ取得が完結する点がServer Componentのシンプルさです。
ステップ4: 記事詳細ページ
詳細ページも同様に、Server Componentで実装します。
// src/app/posts/[id]/page.tsx
import { notFound } from "next/navigation";
import { getPostById } from "@/lib/db";
export default async function PostDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPostById(id);
if (!post) {
notFound();
}
return (
<main>
<h1>{post.title}</h1>
<p>{post.createdAt}</p>
<article>{post.body}</article>
</main>
);
}
存在しないIDにアクセスされた場合は、notFound()を呼んで404ページを表示させます。
ステップ5: 投稿フォーム(Server Actionsとフォームの連携)
最後に投稿フォームを実装します。
フォーム自体はServer Componentのままでも動きますが、送信中の状態表示をしたいので、今回はClient ComponentにしてuseFormStatusを使います。
// src/components/PostForm.tsx
"use client";
import { useFormStatus } from "react-dom";
import { createPostAction } from "@/server/actions/posts";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "投稿中..." : "投稿する"}
</button>
);
}
export function PostForm() {
return (
<form action={createPostAction}>
<div>
<label htmlFor="title">タイトル</label>
<input id="title" name="title" type="text" required />
</div>
<div>
<label htmlFor="body">本文</label>
<textarea id="body" name="body" rows={8} required />
</div>
<SubmitButton />
</form>
);
}
// src/app/posts/new/page.tsx
import { PostForm } from "@/components/PostForm";
export default function NewPostPage() {
return (
<main>
<h1>新しい記事を書く</h1>
<PostForm />
</main>
);
}
useFormStatusはフォームの送信状態を取得できるHookで、送信中はボタンを無効化してユーザーの二重送信を防いでいます。
フォームのactionにServer Actionを渡すだけで、JavaScriptが読み込まれる前でもネイティブのフォーム送信として機能する点もApp Routerらしい設計です。
動かしてみて分かったこと
実際に手を動かして気づいたのが、revalidatePath("/")を書き忘れると、投稿した記事が一覧ページに反映されないという挙動です。
投稿自体は成功しているのに一覧に出てこないので、最初は「保存処理が失敗しているのでは」と疑ってデータストアの実装を何度も見直しました。
結局、Server Actionsで作成・更新・削除をした後は、影響を受けるページのキャッシュを明示的にrevalidatePathかrevalidateTagで無効化する必要がある、という基本を再確認する形になりました。
Server ComponentのデータはデフォルトでNext.jsのキャッシュ機構に乗るため、「変更したのに画面に反映されない」ときは、まずキャッシュの再検証を疑うとよさそうです。
まとめ
この記事のポイント
- Server Actionsを使うと、APIルートを別途作らずにフォーム送信からサーバー処理を呼び出せる
- データ取得はServer Componentで
awaitするだけで完結し、useEffectが不要になる useFormStatusで送信中の状態を取得し、二重送信防止などのUXを簡単に実装できる- データを変更するServer Actionを実行した後は、
revalidatePathでキャッシュを再検証しないと画面に反映されない - 今回のようにデータ層を差し替え可能な形で分離しておくと、後からPrismaなどのORMへ移行しやすい
シリーズを振り返って
Next.js編(N01〜N25)では、App Routerの基本的なファイル規約から始まり、ルーティング、Server ComponentsとClient Componentsの使い分け、データフェッチ、認証、テスト、デプロイまでを一通り解説してきました。
後半のN21〜N24では、実際に手を動かしたときに出会いやすいエラーや、layout.tsxの再評価にまつわる落とし穴、Pages Router完全削除後の依存ライブラリ問題、そしてディレクトリ構成の設計判断について扱いました。
そして今回のN25では、Server Actionsを軸にシンプルなブログアプリを組み上げることで、これまで学んだ要素を一つの成果物としてつなげました。
JavaScript基礎、TypeScript基礎、React、Vue.jsと積み上げてきたこのブログのシリーズも、Next.js編でひとまず一区切りです。
次はNuxt.jsのシリーズが始まる予定です。
Vue.js編で学んだ知識をベースに、Vueの世界におけるフルスタックフレームワークの設計思想を、また一つずつ見ていきたいと思います。
ここまで読んでくださり、ありがとうございました。
