Modern React + Next.js #6 Server Actions and Forms (Wrap-up)

11 min read

Last time we covered progressive loading with Suspense and use(). So far we’ve only read data. In this final post we’ll cover how the user changes data — Next.js’s newest weapon, Server Actions — and wrap up the series with a small mini-project that combines everything.

The complexity of traditional mutations #

Recall the front-end mutation pattern from before.

The familiar pattern
'use client';

function CommentForm() {
  const [text, setText] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);
    setError(null);
    try {
      const res = await fetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      });
      if (!res.ok) throw new Error('Submission failed');
      setText('');
    } catch (err) {
      setError(err.message);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button disabled={submitting}>{submitting ? 'Submitting...' : 'Submit'}</button>
      {error && <p>{error}</p>}
    </form>
  );
}

The boilerplate that recurs every time:

  • Build an API endpoint (/api/comments)
  • JSON serialization/deserialization
  • Handle fetch on the client
  • Loading state, error state
  • Re-fetch data after success (refresh the list)

What if the entire round-trip between client and API endpoint could be expressed as a single function call? Server Actions make that possible.

Server Action basics #

A Server Action is an async function with the 'use server' directive. When called from the client, it runs on the server automatically.

src/app/actions.js
'use server';

export async function createComment(text) {
  // This code always runs on the server
  await db.query('INSERT INTO comments (text) VALUES ($1)', [text]);
}
src/app/CommentForm.jsx
'use client';

import { createComment } from './actions';

export default function CommentForm() {
  async function handleSubmit(formData) {
    const text = formData.get('text');
    await createComment(text);
  }

  return (
    <form action={handleSubmit}>
      <input name="text" />
      <button>Submit</button>
    </form>
  );
}

The key changes:

  • No API routecreateComment is just imported and called like a function
  • No JSON serialization — Next.js handles it
  • <form action={fn}> — connect form submission directly to a function (using browser-native form)
  • The directive makes the security boundary clear — only functions marked with 'use server' can be called from the client

It looks like calling a function, but internally Next.js auto-generates an RPC (Remote Procedure Call). The client sends a function ID and arguments to the server, the server runs the actual function, and the result comes back.

Directive placement — file-level vs function-level #

'use server' can be used in two ways.

1. At the top of a file (every export becomes a Server Action) #

src/app/actions.js
'use server';

export async function createPost(formData) { /* ... */ }
export async function deletePost(id) { /* ... */ }
export async function updatePost(id, data) { /* ... */ }

2. Inside a function (defined inline within a Server Component) #

src/app/posts/page.js (Server Component)
import PostForm from './PostForm';

export default function PostsPage() {
  async function createPost(formData) {
    'use server';
    const title = formData.get('title');
    await db.insertPost(title);
  }

  return <PostForm onCreate={createPost} />;
}

The inline form is convenient because it can access the Server Component’s closure (variables in the enclosing scope). One downside is that a new function is created on every render, so using it indiscriminately for each item in a list can hurt efficiency.

For larger codebases, it’s often better for maintenance to gather actions in a separate file (actions.js).

Updating the page — revalidatePath / revalidateTag #

After a mutation, the screen needs to reflect the new data. Not as a plain reload, but by invalidating the changed page’s cache and re-fetching.

src/app/posts/actions.js
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData) {
  const title = formData.get('title');
  await db.insertPost(title);
  revalidatePath('/posts');  // invalidate the cache for /posts
}

After calling revalidatePath('/posts'), the next visit to /posts re-renders. If the user is already on that page, the screen updates automatically (Next.js refreshes the route’s cache after the Server Action runs).

revalidateTag pairs with the next.tags option from #4.

Tag-based invalidation
// At fetch
const posts = await fetch(url, { next: { tags: ['posts'] } });

// In the Action
revalidateTag('posts');  // invalidates every fetch tagged 'posts'

When several pages use the same data, invalidating them at once is convenient.

useActionState — Action with state #

You’ll often need to display the result of an Action (success/failure messages, validation errors, etc.) in the form. React 19’s new hook useActionState helps here.

src/app/CommentForm.jsx
'use client';

import { useActionState } from 'react';
import { createComment } from './actions';

export default function CommentForm() {
  const [state, formAction] = useActionState(createComment, { message: '' });

  return (
    <form action={formAction}>
      <input name="text" />
      <button>Submit</button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}
src/app/actions.js
'use server';

export async function createComment(prevState, formData) {
  const text = formData.get('text');
  if (!text?.trim()) {
    return { message: 'Please enter some text' };
  }
  await db.insertComment(text);
  return { message: 'Submitted!' };
}

useActionState(action, initialState) returns:

  • First value (state): the Action’s last return value (or the initial state)
  • Second value (formAction): the wrapped function to pass to <form action={...}>
  • (A third value isPending is also available, useful for showing a loading indicator)

The Action function’s first argument is the previous state and the second is FormData (which is why the actions.js signature is (prevState, formData)).

This pattern makes it natural to display validation errors or success messages.

useFormStatus — show “submitting” #

You can detect whether a form is currently submitting (pending) with the useFormStatus hook.

src/app/SubmitButton.jsx
'use client';

import { useFormStatus } from 'react-dom';

export default function SubmitButton({ children }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : children}
    </button>
  );
}
Usage
<form action={formAction}>
  <input name="text" />
  <SubmitButton>Submit</SubmitButton>
</form>

useFormStatus reads the parent form’s state. So no matter where you place SubmitButton inside the form, when that form is submitting, pending becomes true.

You can achieve a similar effect with useActionState’s isPending (the third return value), but useFormStatus lets a separate component subscribe to the form’s status, which is useful for reusable patterns like a SubmitButton.

Optimistic UI — useOptimistic #

This is a pattern that pre-updates the UI while waiting for a mutation’s response, making it feel instant. The useOptimistic hook helps.

src/app/posts/PostList.jsx
'use client';

import { useOptimistic } from 'react';
import { deletePost } from './actions';

export default function PostList({ posts }) {
  const [optimisticPosts, deleteOptimistic] = useOptimistic(
    posts,
    (state, postId) => state.filter(p => p.id !== postId)
  );

  async function handleDelete(id) {
    deleteOptimistic(id);  // remove from UI immediately
    await deletePost(id);  // actual server call
  }

  return (
    <ul>
      {optimisticPosts.map(post => (
        <li key={post.id}>
          {post.title}
          <button onClick={() => handleDelete(post.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

useOptimistic(state, reducer) returns a temporary optimistic state and a function to update it. Click → remove from UI immediately → kick off the server call → when the server response updates the real state, things naturally synchronize. If the server call fails, it auto-rolls back to the original state.

It’s a powerful pattern for dramatically improved perceived speed, but it requires care to ensure data is displayed consistently, so it’s usually picked up later in learning. We just touch the concept here.

Mini-project — a simple guestbook #

Let’s build a small app that combines everything. It’s a simple guestbook that stores data in memory (real DB integration is a separate topic, as noted in the caching section of #4).

src/app/data.js (memory store):

src/app/data.js
const messages = [
  { id: '1', name: 'Admin', text: 'Welcome :)', createdAt: new Date().toISOString() },
];

export async function getMessages() {
  await new Promise(r => setTimeout(r, 200));  // fake delay
  return [...messages].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

export async function addMessage(name, text) {
  messages.push({
    id: crypto.randomUUID(),
    name, text,
    createdAt: new Date().toISOString(),
  });
}

export async function deleteMessage(id) {
  const idx = messages.findIndex(m => m.id === id);
  if (idx >= 0) messages.splice(idx, 1);
}

src/app/guestbook/actions.js:

src/app/guestbook/actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { addMessage, deleteMessage } from '../data';

export async function postMessage(prevState, formData) {
  const name = formData.get('name')?.trim();
  const text = formData.get('text')?.trim();

  if (!name) return { error: 'Please enter your name' };
  if (!text) return { error: 'Please enter a message' };
  if (text.length > 200) return { error: 'Messages must be 200 characters or fewer' };

  await addMessage(name, text);
  revalidatePath('/guestbook');
  return { success: true };
}

export async function removeMessage(id) {
  await deleteMessage(id);
  revalidatePath('/guestbook');
}

src/app/guestbook/page.js:

src/app/guestbook/page.js (Server Component)
import { Suspense } from 'react';
import { getMessages } from '../data';
import MessageForm from './MessageForm';
import { removeMessage } from './actions';

export default function GuestbookPage() {
  return (
    <div style={{ padding: '24px', maxWidth: '600px', margin: '0 auto' }}>
      <h1>Guestbook</h1>
      <MessageForm />
      <Suspense fallback={<p>Loading messages...</p>}>
        <MessageList />
      </Suspense>
    </div>
  );
}

async function MessageList() {
  const messages = await getMessages();

  if (messages.length === 0) {
    return <p>No messages yet. Be the first!</p>;
  }

  return (
    <ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
      {messages.map(msg => (
        <li key={msg.id} style={{ padding: '12px', borderBottom: '1px solid #eee' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <strong>{msg.name}</strong>
            <small style={{ color: '#888' }}>
              {new Date(msg.createdAt).toLocaleString('en-US')}
            </small>
          </div>
          <p style={{ margin: '4px 0' }}>{msg.text}</p>
          <form action={async () => {
            'use server';
            await removeMessage(msg.id);
          }}>
            <button style={{ fontSize: '12px', color: '#888' }}>Delete</button>
          </form>
        </li>
      ))}
    </ul>
  );
}

src/app/guestbook/MessageForm.jsx:

src/app/guestbook/MessageForm.jsx (Client)
'use client';

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

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
      {pending ? 'Posting...' : 'Post'}
    </button>
  );
}

export default function MessageForm() {
  const [state, formAction] = useActionState(postMessage, {});
  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', marginBottom: '16px' }}
    >
      <input name="name" placeholder="Name" required style={{ padding: '6px' }} />
      <textarea name="text" placeholder="Message" rows={3} required 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>
  );
}

That’s all of it. To recap what’s happening in this little app:

  • GuestbookPage (Server Component) — page shell
  • MessageList (Server Component) — renders the message list, wrapped in Suspense so a fallback shows while loading
  • MessageForm (Client Component) — the form, receives server state via useActionState, shows pending status via useFormStatus
  • postMessage (Server Action) — validates + saves + revalidatePath on the server
  • removeMessage (Server Action) — defined inline, connected directly to a form’s action

Not a single API endpoint was created. Validation runs on the server so it can’t be bypassed, data lives safely in server memory, and the screen auto-updates after mutations.

Visit /guestbook to try it. Post messages, try submitting empty (error), try over 200 characters (error), and click delete. After refreshing the page, the messages will still be there — held in the in-memory store as long as the server hasn’t restarted.

Series retrospective #

This series covered the mental model shift to modern React.

#TopicCore idea
1Why Next.js + Server ComponentsCSR/SSR/RSC differences, motivation
2App RouterFile-based routing, layout, dynamic routes
3Server vs Client Components'use client', boundaries, the children pattern
4Data fetching and cachingasync components, fetch options, parallelism
5Suspense and use()Streaming, loading.js, skeletons
6Server ActionsMutations, useActionState, useFormStatus

To re-emphasize the two most important mental models:

  1. Always be aware of “where does this code run?”
  2. Server by default, Client only where needed

Once these two instincts are in place, modern React work feels natural. It’s awkward at first, but once you’re used to it, it’s hard to go back to thinking purely in client-side terms.

The big picture so far #

Across all the React content on this blog, this is the 26th post.

  • React Basics #1–#15 — Client-side React fundamentals
  • Building a Todo App with React #1–#5 — Small practical build on top of those fundamentals
  • Modern React + Next.js #1–#6 — Server Components and the modern paradigm

If you’ve followed all of these, you’ve encountered nearly every key flow in the React ecosystem. You now have the base needed to start building the apps you actually want to build.

Recommended next learning #

After finishing this series, you can branch into topics like these.

  • TypeScript + React — better safety in larger codebases. Next.js + TS is essentially the default
  • Testing — Vitest + React Testing Library, Playwright (E2E)
  • State management libraries — Zustand, Jotai, Redux Toolkit (for larger apps)
  • Data fetching libraries — TanStack Query (when Server Actions alone aren’t enough)
  • Auth — NextAuth.js / Clerk / Lucia
  • DB integration — Prisma, Drizzle, Supabase (real persistence)
  • Deployment — Vercel, Cloudflare Pages, or self-hosting
  • Your real project — ultimately the fastest learning. Start small with the tools above, then grow

Wrap-up #

Thank you for following along this far. We started from the first component of client-side React and made it all the way to building full-stack React apps with Server Components and Server Actions.

React is a fast-evolving library, but it has fundamentals that don’t change — component-oriented thinking, unidirectional data flow, declarative UI. What you learned here is those fundamentals, and new tools will keep stacking on top. The biggest takeaway is the confidence that, knowing the essence, you can pick up any new tool quickly.

Build your own projects, and stack your own small wins. Have a great React journey!

X