C
Next.js/Rendering/Lesson 06

Server Actions — Handle Mutations Directly Without API Routes

40 min·theory
This chapter
3/5
TypeScript

Server Actions — Handle Mutations Directly Without API Routes

💡 Why Learn This? — The End of the 'Form Submit = API Route + fetch + useState' Era

🎯 Creating a single form in the Pages Router era: create `pages/api/posts.ts`, call `fetch('/api/posts', {method:'POST', body})` from the client, manage loading and error state with useState, then call router.refresh on success.
💼 Server Actions: create one function and connect it directly to `
`. The API route, fetch call, and loading state all disappear.
Direct database access inside the function is fine (it runs as Server code). When done, invalidate the cache with a single `revalidatePath` call.
🔗 Works even when JavaScript is disabled — it simply falls back to a standard HTML form. Progressive Enhancement.
📈 React 19's `useActionState` and `useFormStatus` handle the entire form UX (loading indicator, error display) with a single hook.
🏢 실무에서는
Creating posts, comments, likes, and deletions — every mutation becomes simple with a Server Action. The six-step flow of 'create a POST API route → call fetch from the client → manage loading state → optimistic update → revalidate' is reduced to 'one function + form action'.

'use server' · formAction · revalidatePath · useActionState

1. Two Placements for 'use server'

ts
// (a) Top of file — every export in that file becomes a Server Action
// 📁 app/posts/actions.ts
'use server';

export async function createPost(formData: FormData) { ... }
export async function deletePost(id: number) { ... }
ts
// (b) Inside the function body — only that function becomes a Server Action
// Inline declaration inside a Server Component
export default async function Page() {
  async function createPost(formData: FormData) {
    'use server';
    await db.post.create({ data: { title: formData.get('title') as string } });
  }
  return <form action={createPost}><input name="title" /><button>Save</button></form>;
}

Use inline only for short functions. Typically, extract them into a separate file.

2. Signature of

ts
async function createPost(formData: FormData): Promise<void> {
  'use server';
  const title = formData.get('title') as string;
  await db.post.create({ data: { title } });
  revalidatePath('/posts');
}

<form action={createPost}>
  <input name="title" />
  <button type="submit">Save</button>
</form>
  • The first argument is always FormData.
  • The return type defaults to Promise. (When used with useActionState, return state instead.)
  • When the form is submitted, the browser sends a POST request to the server. Next.js automatically intercepts it and executes the function.

3. revalidatePath / revalidateTag — Cache Invalidation

ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });
  revalidatePath('/posts');           // Invalidate the fetch cache for the /posts page
  // revalidateTag('posts');           // Invalidate fetches tagged with 'posts'
}

After creating a new post, you need to show the updated list, so you must clear the cache. Forgetting this causes the classic "I posted something but it doesn't show up" bug.

4. useActionState — Form State Hook (React 19)

tsx
'use client';
import { useActionState } from 'react';
import { createPost } from './actions';

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, {
    error: null as string | null,
    success: false,
  });

  return (
    <form action={formAction}>
      <input name="title" required />
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">Saved!</p>}
    </form>
  );
}

The action signature also needs to accept state:

ts
'use server';
export async function createPost(
  prevState: { error: string | null; success: boolean },
  formData: FormData,
) {
  const title = formData.get('title') as string;
  if (!title) return { error: 'Title is empty', success: false };
  try {
    await db.post.create({ data: { title } });
    revalidatePath('/posts');
    return { error: null, success: true };
  } catch (e) {
    return { error: 'Save failed', success: false };
  }
}

⚠️ React 18's useFormState was renamed to useActionState in React 19. Use useActionState in new code.

5. useFormStatus — Reading Parent Form State in Child Components

tsx
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}

Reads the submission state of the parent <form> directly via a hook. No props drilling needed.

💻 🅰️ Pages Router — API Route + Client fetch + useState Boilerplate
// ❌ Pages Router — 1 form, 3 files + boilerplate

// 📁 pages/api/posts.ts — API Route
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end();
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'Title required' });
  try {
    const post = await db.post.create({ data: { title } });
    return res.status(200).json(post);
  } catch (e) {
    return res.status(500).json({ error: 'Failed to save' });
  }
}

// 📁 components/PostForm.tsx — Client form
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/router';

export function PostForm() {
  const router = useRouter();
  const [title, setTitle] = useState('');
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: FormEvent) {
    e.preventDefault();
    setPending(true);
    setError(null);
    try {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title }),
      });
      if (!res.ok) {
        const { error } = await res.json();
        throw new Error(error);
      }
      setTitle('');
      router.refresh();  // Refresh list
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Unknown error');
    } finally {
      setPending(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} required />
      <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>
      {error && <p>{error}</p>}
    </form>
  );
}

// Disadvantages:
// - 2 files + 60 lines
// - Manual handling of loading, error, cache invalidation
// - Does not work in environments without JS (onSubmit preventDefault)
// - Type safety: Duplicate interface definitions for both API request/response
💻 🅱️ App Router — Consolidated into One Server Action Function
// ✅ App Router — Server Action + useActionState

// 📁 app/posts/actions.ts — Server Actions
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export type FormState = {
  error: string | null;
  success: boolean;
};

export async function createPost(
  prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  const title = (formData.get('title') as string)?.trim();
  if (!title) return { error: 'Title is empty', success: false };

  try {
    await db.post.create({ data: { title } });
  } catch (e) {
    return { error: 'Failed to save', success: false };
  }

  revalidatePath('/posts');           // Invalidate list cache
  return { error: null, success: true };
}

export async function deletePost(id: number): Promise<void> {
  'use server';
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

// 📁 app/posts/PostForm.tsx — Client (form UX)
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost, type FormState } from './actions';

const initialState: FormState = { error: null, success: false };

export function PostForm() {
  const [state, formAction] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" required placeholder="Title" />
      <SubmitButton />
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">Saved!</p>}
    </form>
  );
}

// Read parent form's pending status directly from child component
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  );
}

// 📁 app/posts/page.tsx — Server Component
import { PostForm } from './PostForm';
import { deletePost } from './actions';

export default async function Posts() {
  const posts = await db.post.findMany({ orderBy: { id: 'desc' } });
  return (
    <div>
      <PostForm />
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            {p.title}
            {/* Server Action directly as form action — works without JS */}
            <form action={deletePost.bind(null, p.id)}>
              <button>Delete</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  );
}

// Advantages:
// - 1 file + 1 function
// - 1 line for cache invalidation (revalidatePath)
// - Form submission works even without JS (Progressive Enhancement)
// - Types: action's signature is the contract — automatic inference on both client and server

💡 💡 5 Server Actions Best Practices

1. Always revalidate after a mutation

ts
revalidatePath('/posts');     // By path
revalidateTag('posts');       // By tag (paired with the tags option in fetch)

Forgetting this is the #1 cause of the "I posted something but it doesn't show up" bug.

2. Pass IDs via argument binding

tsx
<form action={deletePost.bind(null, postId)}>
  <button>Delete</button>
</form>

ID goes in the first argument, formData in the second. No need for hidden inputs.

3. useActionState is the React 19 standard (replaces the old useFormState)

tsx
import { useActionState } from 'react';
const [state, formAction, isPending] = useActionState(action, initialState);

The old useFormState (react-dom) is deprecated.

4. Calling redirect() inside a Server Action is fine

ts
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
  'use server';
  const post = await db.post.create({ ... });
  redirect(`/posts/${post.id}`);
}

Like a throw, it terminates the flow — code after it does not execute.

5. Security — Input validation is required inside Server Actions too

Even if the client validates the form, someone can send a direct POST request. Validate inside the action with something like zod:

ts
const schema = z.object({ title: z.string().min(1).max(200) });
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: 'Invalid input', success: false };

⚡ Try It Yourself — Server Action Flow Simulation

Simulate the Server Action flow: form submission → mutation → cache invalidation.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

What happens if you do not call `revalidatePath('/posts')` after a mutation inside a Server Action?
💡 Next.js fetch defaults to force-cache, so it caches data for the same URL. If you don't call `revalidatePath` or `revalidateTag` after a mutation, the cache remains stale and users see old data. This is the cause of 80% of 'saved to DB but not showing on screen' bugs. The pattern is to always call revalidate at the end of every mutation.
Server Actions — Mutation Without API Routes - Next.js