Next.jsでブログを作る #4 コメント (Server Actions)

前回まで私たちのブログは読み取り専用でした。今回は記事ごとにコメントを付けられるようにします。モダンReactシリーズで学んだServer Actionsが本格的に登場する記事です。

データの保存先 — どこに置くか? #

コメントをどこかに保存しなければなりません。選択肢。

  1. DB (PostgreSQL、SQLite、Supabase) — 実サービスなら標準的な選択
  2. JSONファイル — fsで読み書き。学習用としてシンプル
  3. メモリ変数 — プロセスが生きている間だけ保持

このシリーズはメモリ変数で進めます。理由は次のとおりです。

  • DBセットアップが不要で学習を単純化
  • Server Actionsの中核(検証 / 呼び出し / 更新)に集中できる
  • 実戦のDB連携は別の主題 (Prisma、Drizzleなど)

欠点は明確です — サーバーを再起動するとコメントが消えます。学習用には十分で、実際にデプロイするときにはDBか外部サービスに置き換えればOKです。

注記
運用時に最も軽い選択肢はVercel KV (Redisベース)またはVercel Postgresです。Supabaseの無料tierも学習/小規模運用に十分です。このシリーズでは扱いませんが、本番に持っていくときに、このメモリストアを差し替えるのが最後の作業になるはずです。

コメントデータモジュール #

src/app/lib/comments.jsを作ります。

src/app/lib/comments.js
const commentsBySlug = {};

export function getComments(slug) {
  return commentsBySlug[slug] ?? [];
}

export function addComment(slug, comment) {
  if (!commentsBySlug[slug]) commentsBySlug[slug] = [];
  commentsBySlug[slug].push({
    id: crypto.randomUUID(),
    ...comment,
    createdAt: new Date().toISOString(),
  });
}

データの形は次のとおりです。

{
  id: '...uuid...',
  author: '太郎',
  text: 'いい記事ですね',
  createdAt: '2026-05-15T10:30:00.000Z',
}

開発中はhot reload時にメモリが空になることがある点に注意してください。devサーバー再起動時も同様です。

Server Actionの定義 #

src/app/posts/[slug]/actions.jsを作ります。

src/app/posts/[slug]/actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { addComment } from '../../lib/comments';

export async function postComment(slug, prevState, formData) {
  const author = formData.get('author')?.trim();
  const text = formData.get('text')?.trim();

  if (!author) return { error: '作成者名を入力してください' };
  if (author.length > 30) return { error: '作成者名は30文字以内で' };
  if (!text) return { error: 'コメント内容を入力してください' };
  if (text.length > 500) return { error: 'コメントは500文字以内で' };

  addComment(slug, { author, text });
  revalidatePath(`/posts/${slug}`);

  return { success: true };
}

主なポイント。

'use server' #

ファイルの先頭に置くと、そのファイルのすべてのexportがServer Actionになります。

第1引数のslug #

Server Actionはbindで引数を事前に束ねて使うことができます (下で使用)。このパターンを使うと、どの記事のコメントなのかという情報が安全に伝わります。

prevStateformData #

useActionStateから呼ばれるActionは、第1引数が前のstate、第2引数がFormDataです。検証結果をオブジェクトとして返すと、それが次のstateになります。

検証 #

サーバーで検証することが重要です。クライアントの検証はUX改善のためであって、セキュリティのためではありません。誰かが直接fetchで迂回呼び出ししても、サーバーで止まらなければなりません。

revalidatePath #

コメントが追加されたら/posts/${slug}ページのキャッシュを無効化します。次のレンダリングで新しいコメントが画面に反映されます。

コメントフォーム — Client Component #

src/app/posts/[slug]/CommentForm.jsx

src/app/posts/[slug]/CommentForm.jsx
'use client';

import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { postComment } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
      {pending ? '送信中...' : 'コメントする'}
    </button>
  );
}

export default function CommentForm({ slug }) {
  const action = postComment.bind(null, slug);
  const [state, formAction] = useActionState(action, {});
  const formRef = useRef(null);

  useEffect(() => {
    if (state.success) {
      formRef.current?.reset();
    }
  }, [state]);

  return (
    <form
      ref={formRef}
      action={formAction}
      style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '24px' }}
    >
      <input
        name="author"
        placeholder="作成者"
        required
        maxLength={30}
        style={{ padding: '6px' }}
      />
      <textarea
        name="text"
        placeholder="コメント内容"
        rows={3}
        required
        maxLength={500}
        style={{ padding: '6px' }}
      />
      <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
        <SubmitButton />
        {state.error && (
          <span style={{ color: 'tomato', fontSize: '14px' }}>{state.error}</span>
        )}
        {state.success && (
          <span style={{ color: 'green', fontSize: '14px' }}>送信完了!</span>
        )}
      </div>
    </form>
  );
}

主なポイント。

bindでslugを束ねる #

const action = postComment.bind(null, slug);

postComment(slug, prevState, formData)のシグネチャからslugをあらかじめ束ねて(prevState, formData) => ...の形にします。useActionState(prevState, formData) => newStateの形を期待しているからです。

このパターンのおかげで、クライアントがslugをフォームのhidden inputで渡さずに済みます (渡してもいいのですが、hidden inputは誰でも改ざんできるので、サーバー内で決まる方が安全)。

useActionState #

const [state, formAction] = useActionState(action, {});
  • 1つ目の戻り値(state): Actionの最後の戻り値。私たちのケースでは{ error: '...' }または{ success: true }
  • 2つ目の戻り値(formAction): フォームのactionに渡すラップされた関数
  • 初期stateは{} (何のメッセージもない初期状態)

成功時にフォームをリセット #

useEffect(() => {
  if (state.success) {
    formRef.current?.reset();
  }
}, [state]);

state.successtrueになるとformRef.current.reset()でフォームの入力値を初期化します。UX上自然な動作。

useFormStatusで送信中の表示 #

SubmitButtonを別のコンポーネントに分けて、useFormStatusで親フォームのpending状態を受け取りました。フォームが送信中ならボタンが非活性 + テキスト変更。

コメント一覧 #

src/app/posts/[slug]/CommentList.jsx

src/app/posts/[slug]/CommentList.jsx
import { getComments } from '../../lib/comments';

export default function CommentList({ slug }) {
  const comments = getComments(slug);

  if (comments.length === 0) {
    return <p style={{ color: '#888', marginTop: '16px' }}>まだコメントがありません最初のコメントを投稿してみましょう!</p>;
  }

  return (
    <ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
      {comments.map(comment => (
        <li key={comment.id} style={{ padding: '12px', borderBottom: '1px solid #eee' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <strong>{comment.author}</strong>
            <small style={{ color: '#888' }}>
              {new Date(comment.createdAt).toLocaleString('ko-KR')}
            </small>
          </div>
          <p style={{ margin: '4px 0', whiteSpace: 'pre-wrap' }}>{comment.text}</p>
        </li>
      ))}
    </ul>
  );
}

これはServer Componentです ('use client'がない)。サーバーでgetCommentsを使ってメモリストアからコメントを読み、そのまま描画します。

white-space: pre-wrapは、ユーザーが入力した改行を画面にそのまま表示してくれるCSSプロパティです。

記事詳細ページにコメント領域を組み込む #

src/app/posts/[slug]/page.jsを修正。

src/app/posts/[slug]/page.js (修正)
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllSlugs } from '../../lib/posts';
import { compileMDX } from 'next-mdx-remote/rsc';
import remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
import CommentList from './CommentList';
import CommentForm from './CommentForm';

export async function generateStaticParams() {
  return getAllSlugs().map(slug => ({ slug }));
}

export default async function PostPage({ params }) {
  const { slug } = await params;
  const post = getPostBySlug(slug);

  if (!post || post.frontmatter.draft) {
    notFound();
  }

  const { content } = await compileMDX({
    source: post.content,
    options: {
      mdxOptions: {
        remarkPlugins: [remarkGfm],
        rehypePlugins: [[rehypePrettyCode, { theme: 'github-light' }]],
      },
    },
  });

  return (
    <article style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h1>{post.frontmatter.title}</h1>
      <small style={{ color: '#888' }}>{post.frontmatter.date}</small>
      <p style={{ color: '#555' }}>{post.frontmatter.description}</p>
      <hr />
      {content}

      <section style={{ marginTop: '48px' }}>
        <h2>コメント</h2>
        <Suspense fallback={<p>コメントを読み込み中...</p>}>
          <CommentList slug={slug} />
        </Suspense>
        <CommentForm slug={slug} />
      </section>
    </article>
  );
}

<Suspense>でコメント領域を包みました。私たちのメモリストアは速いのでfallbackが見えないこともありますが、本物のDBから取ってくるならコメントが少し遅れて現れる間も記事本文はすぐに見える、良いパターンです。段階的なロードが自然に入るわけです。

動作確認 #

保存して以下を試してみてください。

  1. 記事詳細ページに移動
  2. 本文の下に「コメント」セクションが見えて「まだコメントがありません」の案内
  3. フォームに作成者/内容を入力後に送信
  4. 自動でコメント一覧に追加される (revalidatePathの効果)
  5. フォーム入力値が自動でリセット
  6. 空入力で送信を試す → エラーメッセージ
  7. 500文字を超える入力 → エラーメッセージ
  8. 別の記事に移動して戻っても (サーバーを再起動していなければ) コメントが維持
  9. 別の記事にコメントを付けると、記事ごとにコメントが別々に保管される

/posts/hello-world/posts/learning-reactの両方にコメントを付けてみると、記事別に分離されているのが確認できます — commentsBySlugオブジェクトのキーがslugなので、自然にそう動きます。

発展の方向 — もっと作れるもの #

今のコメントシステムにはさまざまな改善余地があります。

  • 削除 — 作成者(または管理者)が自分のコメントを削除。UUIDでターゲットを識別
  • 編集 — インライン編集 (Todoシリーズ#4のパターンを再活用可能)
  • いいね — コメントにいいねを押す (楽観的UI = useOptimisticの良い例)
  • 返信コメントparentIdフィールドを追加
  • 認証 — ログインユーザーのみ投稿、作成者を自動入力
  • スパム対策 — reCAPTCHA、rate limiting

それぞれが別の学習領域で、このシリーズではコアの流れ(フォーム → Action → revalidate)だけを扱いました。自分自身で発展させるのに良い領域です。

よくある落とし穴 #

1. revalidatePathを忘れると画面が更新されない #

🚫 revalidate の漏れ
'use server';
export async function postComment(slug, prevState, formData) {
  // ... 検証 ...
  addComment(slug, { ... });
  return { success: true };
  // revalidatePath 呼び出し漏れ → 画面が更新されない
}

データはサーバーで追加されたのに、クライアントのページキャッシュがそのままなので新しいコメントが表示されません。ユーザーがページを再読み込みしないと見えない不自然なUXになるので、revalidatePathを忘れないでください。

2. Actionの中で直接throw #

🚫 throw でエラー処理
export async function postComment(slug, prevState, formData) {
  if (!formData.get('text')) throw new Error('内容なし');
  // ...
}

throwすると最も近いerror.jsが横取りして、ページ全体がエラー画面に変わります。検証失敗はフォームの中に文言として表示するのが自然なので、returnでエラーメッセージのオブジェクトを返してください。本当に予期しないエラー(DBダウンなど)はthrowが正解です。

3. Client Componentで直接メモリストアをimport #

🚫 動作しない
'use client';
import { getComments } from '../../lib/comments';
// ...

commentsBySlugはサーバーメモリに住む変数です。Client Componentはブラウザで実行されるので、その変数にはアクセスできません。サーバーデータへのアクセスは常にServer ComponentまたはServer Actionを経由する必要があります。

おわりに #

今回の記事ではコメント機能を通してServer Actionsを実戦に適用しました。

  • メモリストア + getComments/addComment関数
  • 'use server' Actionで検証 + 保存 + revalidatePath
  • クライアントフォーム: useActionStateで結果を受け取り + useFormStatusでpending表示
  • bindでServer Actionに追加引数を束ねる
  • 成功時にフォーム自動リセット、エラーメッセージのインライン表示
  • コメント一覧はServer Componentでシンプルに

今、私たちのブログは基本機能をすべて備えています。記事を書く、見る、分類する、検索する、コメントを付けるまで。次の記事でありシリーズ最後の「Next.jsでブログを作る #5 SEOとデプロイ」では、metadata APIで検索エンジン最適化を行い、sitemapとRSSを作って、Vercelにデプロイして実際にインターネットに公開するところまで進みます。そしてシリーズ全体 + Reactコンテンツ26編の全体像を振り返って締めくくります。

X