Build a Blog with Next.js #4: Comments (Server Actions)

Up to last time, our blog was read-only. This time we add comments per post — the post where Server Actions from the Modern React series take center stage.

Where to store the data? #

We need to store comments somewhere. Options:

  1. DB (PostgreSQL, SQLite, Supabase) — the standard choice for real services
  2. JSON file — read/write with fs. Simple for learning
  3. In-memory variable — kept only while the process is alive

This series goes with in-memory variables. Reasons:

  • No DB setup, less learning overhead
  • Lets us focus on the heart of Server Actions (validate / call / refresh)
  • Real DB integration is its own topic (Prisma, Drizzle, etc.)

The drawback is obvious — restarting the server wipes the comments. Fine for learning; in real deployment, swap it for a DB or external service.

Note
For ops, the lightest options are Vercel KV (Redis-based) or Vercel Postgres. Supabase’s free tier is plenty for learning/small ops too. We don’t cover them in this series, but swapping out this in-memory store for one of them will be the final task when going to production.

Comment data module #

Create 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(),
  });
}

Data shape:

{
  id: '...uuid...',
  author: 'Alex',
  text: 'Nice post!',
  createdAt: '2026-05-15T10:30:00.000Z',
}

Note that during development, hot reload may clear the in-memory store. Same for restarting the dev server.

Define the Server Action #

Create 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: 'Please enter your name' };
  if (author.length > 30) return { error: 'Name must be 30 characters or fewer' };
  if (!text) return { error: 'Please enter a comment' };
  if (text.length > 500) return { error: 'Comment must be 500 characters or fewer' };

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

  return { success: true };
}

Key points:

'use server' #

Place it at the top of the file and every export becomes a Server Action.

First argument slug #

Server Actions can have arguments pre-bound with bind (used below). This pattern carries the post identity safely.

prevState and formData #

Actions called via useActionState receive the previous state as the first argument and FormData as the second. Returning a validation object becomes the next state.

Validation #

Validating on the server matters. Client validation is for UX, not security. Even if someone bypasses it with a direct fetch call, the server must reject it.

revalidatePath #

When a comment is added, invalidate the cache for /posts/${slug}. The next render reflects the new comment.

Comment form — 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 ? 'Submitting...' : 'Post comment'}
    </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="Name"
        required
        maxLength={30}
        style={{ padding: '6px' }}
      />
      <textarea
        name="text"
        placeholder="Comment"
        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' }}>Posted!</span>
        )}
      </div>
    </form>
  );
}

Key points:

Bind slug with bind #

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

We pre-bind slug from the postComment(slug, prevState, formData) signature, turning it into (prevState, formData) => .... useActionState expects a (prevState, formData) => newState shape.

This saves us from passing the slug as a hidden form input (which would work, but hidden inputs can be tampered with — having the server own the slug is safer).

useActionState #

const [state, formAction] = useActionState(action, {});
  • First return (state): the action’s last return value. Here { error: '...' } or { success: true }
  • Second return (formAction): a wrapped function for the form’s action prop
  • Initial state is {} (no message yet)

Reset on success #

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

When state.success becomes true, formRef.current.reset() clears the inputs. A natural UX touch.

useFormStatus for pending state #

We split out SubmitButton so it can use useFormStatus to read the parent form’s pending state. While submitting, the button is disabled and the text changes.

Comment list #

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' }}>No comments yet. Be the first!</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('en-US')}
            </small>
          </div>
          <p style={{ margin: '4px 0', whiteSpace: 'pre-wrap' }}>{comment.text}</p>
        </li>
      ))}
    </ul>
  );
}

This is a Server Component (no 'use client'). On the server, getComments reads from the in-memory store and renders directly.

white-space: pre-wrap is the CSS that preserves user-entered line breaks on screen.

Plug the comments section into the post detail page #

Update src/app/posts/[slug]/page.js:

src/app/posts/[slug]/page.js (modified)
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>Comments</h2>
        <Suspense fallback={<p>Loading comments...</p>}>
          <CommentList slug={slug} />
        </Suspense>
        <CommentForm slug={slug} />
      </section>
    </article>
  );
}

We wrapped the comments section in <Suspense>. Our in-memory store is fast enough that the fallback may never appear, but with a real DB, comments could arrive slightly after the post body — a natural fit for progressive loading.

Verify it works #

Save and try:

  1. Navigate to a post detail page
  2. A “Comments” section appears below the body with “No comments yet”
  3. Fill in name/comment and submit
  4. The comment list updates automatically (revalidatePath effect)
  5. The form inputs reset
  6. Try submitting empty fields → error message
  7. Try a 500+ char comment → error message
  8. Navigate to another post and back (without restarting the server) → comments persist
  9. Posting comments on different posts keeps them separate

Add comments on /posts/hello-world and /posts/learning-react, and you’ll see they’re separate per post — the keys of commentsBySlug are slugs, so this happens naturally.

Where to grow next #

Many improvements you can layer on:

  • Delete — let authors (or admins) remove their own comments. UUID identifies the target
  • Edit — inline editing (you can reuse the pattern from Todo series #4)
  • Likes — like a comment (a great example for useOptimistic for optimistic UI)
  • Replies — add a parentId field
  • Authentication — only logged-in users can comment, with author auto-filled
  • Spam protection — reCAPTCHA, rate limiting

Each is its own learning project. This series covered the core flow (form → action → revalidate); there’s plenty of fertile ground to explore from here.

Common pitfalls #

1. Forgetting revalidatePath leaves the screen stale #

🚫 missing revalidate
'use server';
export async function postComment(slug, prevState, formData) {
  // ... validation ...
  addComment(slug, { ... });
  return { success: true };
  // missing revalidatePath → screen doesn't update
}

Data was added on the server, but the client’s page cache stays unchanged, so the new comment doesn’t appear. The user has to refresh manually for it to show — bad UX. Don’t forget revalidatePath.

2. Throwing inside an Action #

🚫 throw to handle errors
export async function postComment(slug, prevState, formData) {
  if (!formData.get('text')) throw new Error('Empty content');
  // ...
}

A throw is caught by the nearest error.js and the entire page becomes an error screen. Validation failures should display as inline messages on the form, so return an error object instead. Truly unexpected errors (DB down, etc.) should throw.

3. Importing the in-memory store directly into a Client Component #

🚫 doesn't work
'use client';
import { getComments } from '../../lib/comments';
// ...

commentsBySlug is a variable that lives in server memory. Client Components run in the browser and can’t reach it. Server data must always go through a Server Component or Server Action.

Wrap-up #

This post applied Server Actions to a real build via comments.

  • In-memory store + getComments/addComment functions
  • A 'use server' action that validates, stores, and revalidatePaths
  • Client form: receive results via useActionState, show pending via useFormStatus
  • bind to attach extra arguments to a Server Action
  • Auto form reset on success, inline error messages
  • Comment list as a simple Server Component

Our blog now has the basics covered — writing, viewing, categorizing, searching, commenting. In the next and final post, “Build a Blog with Next.js #5 SEO and deployment,” we’ll do search engine optimization with the metadata API, build sitemap and RSS, and deploy to Vercel so it’s actually on the internet. We’ll also retrospect across this series and the 26 React posts overall.

X