Server Actions in Next.js: Mutations Without Writing an API

A
Author
19 Sep, 2026 1 min read 883 views

A Server Action is an async function that runs on the server but can be called from a form or a client component. Next.js creates the endpoint for you.

A form that writes to the database

// app/todos/page.tsx
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export default async function TodosPage() {
  const todos = await db.todo.findMany();

  async function addTodo(formData: FormData) {
    'use server';
    const title = String(formData.get('title') ?? '').trim();
    if (!title) return;
    await db.todo.create({ data: { title } });
    revalidatePath('/todos'); // refresh the list
  }

  return (
    <>
      <form action={addTodo}>
        <input name="title" />
        <button>Add</button>
      </form>
      <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
    </>
  );
}

This form works even before JavaScript loads (progressive enhancement).

Actions in their own file

// app/actions.ts
'use server';

import { z } from 'zod';
import { auth } from '@/lib/auth';

const Schema = z.object({ title: z.string().min(1).max(200) });

export async function createTodo(prevState: unknown, formData: FormData) {
  const session = await auth();
  if (!session) return { error: 'Please sign in.' };

  const parsed = Schema.safeParse({ title: formData.get('title') });
  if (!parsed.success) return { error: 'Title is required.' };

  await db.todo.create({ data: { title: parsed.data.title, userId: session.user.id } });
  return { ok: true };
}

Use it from a Client Component with useActionState(createTodo, {}) to show errors and pending state.

Security: actions are public endpoints

  • Anyone can call an action with any arguments. Authenticate and authorise inside every action.
  • Validate all input with a schema; never trust FormData.
  • Don't return more data than the client needs.
  • Closures over server values are encrypted, but keep secrets out of them anyway.

After a mutation

  • revalidatePath('/todos') - refresh a route's data.
  • revalidateTag('todos', 'max') - refresh data tagged with cacheTag (stale-while-revalidate).
  • redirect('/todos/' + id) - navigate after creating something.
Was this article helpful?

Comments

No comments yet. Have a question or a tip? Be the first to share it.

Leave a comment

Comments are reviewed before they appear.