【Next.js】Next.jsでシンプルなブログアプリを作ってみる(Server Actions活用)

JavaScript

こんにちは、かつコーチです。

前回はディレクトリ構成のベストプラクティスを解説しました。

今回はいよいよ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で作成・更新・削除をした後は、影響を受けるページのキャッシュを明示的にrevalidatePathrevalidateTagで無効化する必要がある、という基本を再確認する形になりました。

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の世界におけるフルスタックフレームワークの設計思想を、また一つずつ見ていきたいと思います。

ここまで読んでくださり、ありがとうございました。

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