GetLaunchpad
Back to blog
7 min read

Next.js Server Actions: forms, mutations, and when to use them

Server Actions let you call server-side code directly from client components without writing API routes. Here's how they work, the security requirements, useActionState, revalidatePath, and when to use API routes instead.

Server Actions are Next.js's answer to form handling and mutations without writing separate API routes. They let you call server-side code directly from client components — the function runs on the server, but you call it like a normal async function.

Here's how they work, when to use them, and the security considerations you need to understand before using them in production.

What Server Actions are

A Server Action is an async function marked with "use server" that runs on the server even when called from a client component. Under the hood, Next.js creates an HTTP endpoint for each action and handles the serialization automatically.

// app/actions/projects.ts
"use server";

import { auth } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
import { adminClient } from "@/lib/supabase/admin";
import { getUserDbId } from "@/lib/db";

export async function createProject(name: string) {
  const { userId } = await auth();
  if (!userId) throw new Error("Unauthorized");

  const dbUserId = await getUserDbId(userId);
  if (!dbUserId) throw new Error("User not found");

  const { data, error } = await adminClient
    .from("projects")
    .insert({ name, user_id: dbUserId })
    .select()
    .single();

  if (error) throw new Error(error.message);

  revalidatePath("/dashboard");
  return data;
}

Calling from a client component

Import and call the action directly — no fetch, no API route, no JSON serialization to manage:

"use client";

import { useState } from "react";
import { createProject } from "@/app/actions/projects";

export function CreateProjectForm() {
  const [name, setName] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError(null);
    try {
      await createProject(name);
      setName("");
    } catch (err) {
      setError(err instanceof Error ? err.message : "Something went wrong");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Project name"
        className="rounded border px-3 py-2 text-sm"
      />
      <button type="submit" disabled={loading} className="rounded bg-zinc-900 px-4 py-2 text-sm text-white">
        {loading ? "Creating…" : "Create"}
      </button>
      {error && <p className="text-sm text-red-500">{error}</p>}
    </form>
  );
}

Using with the native form action

Server Actions can also be passed directly to form action props, which works even without JavaScript (progressive enhancement):

// Server component — no "use client" needed
import { createProject } from "@/app/actions/projects";

export function CreateProjectForm() {
  async function handleAction(formData: FormData) {
    "use server";
    const name = formData.get("name") as string;
    await createProject(name);
  }

  return (
    <form action={handleAction}>
      <input name="name" placeholder="Project name" />
      <button type="submit">Create</button>
    </form>
  );
}

This pattern is useful for simple forms in server components. For complex interactive forms with validation feedback, the client component approach gives more control.

useFormState for validation feedback

React 19 (which Next.js 15+ uses) provides useActionState (previously useFormState) for progressive form state:

"use client";

import { useActionState } from "react";
import { createProject } from "@/app/actions/projects";

type State = { error?: string; success?: boolean };

async function createProjectAction(
  prevState: State,
  formData: FormData
): Promise<State> {
  const name = formData.get("name") as string;
  if (!name?.trim()) return { error: "Name is required" };

  try {
    await createProject(name);
    return { success: true };
  } catch (err) {
    return { error: err instanceof Error ? err.message : "Failed" };
  }
}

export function CreateProjectForm() {
  const [state, formAction, isPending] = useActionState(createProjectAction, {});

  return (
    <form action={formAction}>
      <input name="name" placeholder="Project name" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating…" : "Create"}
      </button>
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">Project created!</p>}
    </form>
  );
}

Security: treat Server Actions like API routes

Server Actions are just API endpoints with a different calling convention. The security requirements are identical to route handlers:

"use server";

import { auth } from "@clerk/nextjs/server";
import { z } from "zod";

const schema = z.object({
  name: z.string().min(1).max(100),
});

export async function createProject(rawName: string) {
  // 1. Authenticate
  const { userId } = await auth();
  if (!userId) throw new Error("Unauthorized");

  // 2. Validate
  const { name } = schema.parse({ name: rawName });

  // 3. Do the work
  // ...
}

When to use Server Actions vs API routes

Use Server Actions for:

Use API routes for:

revalidatePath and revalidateTag

After a mutation, call revalidatePath to clear the Next.js cache for a page, or revalidateTag to clear tagged cache entries:

import { revalidatePath, revalidateTag } from "next/cache";

// After creating/updating/deleting a project
revalidatePath("/dashboard"); // re-fetch the dashboard page data
revalidatePath(`/projects/${id}`); // re-fetch a specific project page

// Or with tags (requires tagging fetch calls)
revalidateTag("projects"); // invalidate all cached data tagged "projects"
Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles