LearnFrenzy
Sign in Take a free test

Server Actions and Route Handlers · Lesson 14 of 24

Next.js Server Actions

8 min read Updated Nov 8, 2026 Saurabh Samir

A Server Action is an async function marked "use server" that runs on the server but can be called from a form or a button in the browser. Next.js turns the call into a POST request for you, so you can save data without writing an API route, without fetch, and – for forms – even before JavaScript has loaded.

In this lesson

  • A form that calls a Server Action
  • Validation errors with useActionState and zod
  • Pending states and redirects
  • Security: every action is a public endpoint

Your first Server Action

// lib/guestbook.ts
const entries: string[] = ["Asha: loving the course!"];

export async function getEntries() {
  return [...entries];
}

export async function addEntry(text: string) {
  entries.push(text);                          // in a real app: INSERT INTO a database
}
// app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { refresh } from "next/cache";
import { addEntry, getEntries } from "@/lib/guestbook";

async function sign(formData: FormData) {
  "use server";                                // runs on the server, called from the browser
  const name = String(formData.get("name") ?? "").trim();
  const message = String(formData.get("message") ?? "").trim();
  if (!name || !message) return;
  await addEntry(`${name}: ${message}`);
  refresh();                                   // re-render this page with the new entry
}

async function Entries() {
  await connection();                          // always read the latest entries
  const entries = await getEntries();
  return <ul>{entries.map((e) => <li key={e}>{e}</li>)}</ul>;
}

export default function Guestbook() {
  return (
    <main>
      <h1>Guestbook</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Entries />
      </Suspense>
      <form action={sign}>
        <input name="name" aria-label="Name" placeholder="Name" />
        <input name="message" aria-label="Message" placeholder="Message" />
        <button>Sign</button>
      </form>
    </main>
  );
}

Page

Guestbook
• Asha: loving the course!
[Name…] [Message…] [Sign]

› type “Ravi” into Name
Guestbook
• Asha: loving the course!
[Ravi] [Message…] [Sign]

› type “Server Actions are neat” into Message
Guestbook
• Asha: loving the course!
[Ravi] [Server Actions are neat] [Sign]

› click “Sign”
Guestbook
• Asha: loving the course!
• Ravi: Server Actions are neat
[Name…] [Message…] [Sign]

One round trip

  1. Submitthe browser POSTs the form data to the page with an encrypted action ID
  2. RunNext.js calls sign(formData) on the server
  3. Re-renderrefresh() (or updateTag/revalidatePath) re-renders the affected UI
  4. Respondthe action result and the updated UI come back in the same response

Because the form uses a real action, it works even if JavaScript hasn't loaded yet (progressive enhancement); once it has, the form is submitted without a full page reload, and React resets the inputs after a successful action.

Actions in their own file

Put "use server" at the top of a file to make every export an action. This is the usual structure, and it's required when a Client Component needs to import the action:

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

import { z } from "zod";

const SignupSchema = z.object({
  email: z.email("Enter a valid email"),
  course: z.enum(["react", "nextjs"], "Choose a course"),
});

export type SignupState = { ok?: string; errors?: { email?: string[]; course?: string[] }; email?: string };

export async function enroll(prev: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get("email") ?? "");
  const parsed = SignupSchema.safeParse({ email, course: formData.get("course") });   // never trust form data
  if (!parsed.success) {
    return { errors: z.flattenError(parsed.error).fieldErrors, email };               // expected error: return it
  }
  await new Promise((r) => setTimeout(r, 300));                                       // save to the database
  return { ok: `Enrolled ${parsed.data.email} in ${parsed.data.course}` };
}
// app/enroll-form.tsx
"use client";

import { useActionState } from "react";
import { enroll, type SignupState } from "./actions";

export function EnrollForm() {
  const [state, formAction, pending] = useActionState<SignupState, FormData>(enroll, {});
  return (
    <form action={formAction}>
      <label>
        Email <input name="email" defaultValue={state.email} key={state.email} />
      </label>
      {state.errors?.email && <p role="alert">{state.errors.email[0]}</p>}
      <label>
        Course
        <select name="course" defaultValue="">
          <option value="" disabled>Pick one</option>
          <option value="react">React.js</option>
          <option value="nextjs">Next.js</option>
        </select>
      </label>
      {state.errors?.course && <p role="alert">{state.errors.course[0]}</p>}
      <button disabled={pending}>{pending ? "Enrolling…" : "Enroll"}</button>
      {state.ok && <p>✔ {state.ok}</p>}
    </form>
  );
}
// app/page.tsx
import { EnrollForm } from "./enroll-form";

export default function Page() {
  return (
    <main>
      <h1>Enroll for free</h1>
      <EnrollForm />
    </main>
  );
}

Page

Enroll for free
Email [] Course [Pick one ▾] [Enroll]

› type “asha” into Email
Enroll for free
Email [asha] Course [Pick one ▾] [Enroll]

› click “Enroll”
Enroll for free
Email [asha]
Enter a valid email
Course [Pick one ▾]
Choose a course
[Enroll]

› clear Email and type “asha@example.com”
Enroll for free
Email [asha@example.com]
Enter a valid email
Course [Pick one ▾]
Choose a course
[Enroll]

› choose “Next.js” in Course
Enroll for free
Email [asha@example.com]
Enter a valid email
Course [Next.js ▾]
Choose a course
[Enroll]

› click “Enroll”
Enroll for free
Email [] Course [Pick one ▾] [Enroll]
✔ Enrolled asha@example.com in nextjs
  • The action's first parameter is the previous state, because it's used with useActionState.
  • Expected errors are returned, not thrown – the form shows them next to each field. Throwing is for unexpected failures, which go to error.tsx.
  • zod checks the shape and gives friendly messages; the same schema can validate API requests too.

Redirect after a mutation

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

import { redirect } from "next/navigation";

export async function createCourse(formData: FormData) {
  const title = String(formData.get("title") ?? "").trim() || "Untitled";
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
  // await db.course.create({ data: { title, slug } }); updateTag("courses");
  redirect(`/courses/${slug}?created=1`);       // throws – put it last
}
// app/page.tsx
import { createCourse } from "./actions";

export default function NewCourse() {
  return (
    <form action={createCourse}>
      <input name="title" aria-label="Course title" placeholder="Course title" />
      <button>Create course</button>
    </form>
  );
}
// app/courses/[slug]/page.tsx
import { Suspense } from "react";

async function Course({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return <h1>Course “{slug}” created</h1>;
}

export default function Page({ params }: PageProps<"/courses/[slug]">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Course params={params} />
    </Suspense>
  );
}

Page

URL: /
[Course title…] [Create course]

› type “Docker Basics” into Course title
URL: /
[Docker Basics] [Create course]

› click “Create course”
URL: /courses/docker-basics?created=1
Course “docker-basics” created

Security: treat every action as a public API

Next.js encrypts action IDs, checks the request origin against the host (a CSRF protection) and limits request bodies to 1 MB. But anyone can still send a POST to your action without using your form. So inside every action:

"use server";

import { z } from "zod";
import { verifySession } from "@/lib/session";

export async function deleteComment(commentId: string) {
  const session = await verifySession();                          // 1. authenticate
  if (!session) throw new Error("Unauthorized");
  const id = z.uuid().parse(commentId);                           // 2. validate input
  const comment = await db.comment.findUnique({ where: { id } });
  if (comment?.authorId !== session.userId && session.role !== "admin") {
    throw new Error("Forbidden");                                 // 3. authorise this user for this record
  }
  await db.comment.delete({ where: { id } });
  return { deleted: true };                                       // 4. return only what the UI needs
}

Hiding the button isn't security

Only rendering a delete button for admins doesn't protect the action – the check must be inside the action itself. The authentication chapter builds verifySession.

Practice

Try it yourself

  1. Make a newsletter form with a Server Action that returns "Thanks, NAME!" through useActionState, or "Please enter your name" when it's empty.

    Show solution
    // app/actions.ts
    "use server";
    
    export async function subscribe(prev: string, formData: FormData) {
      const name = String(formData.get("name") ?? "").trim();
      if (!name) return "Please enter your name";
      return `Thanks, ${name}!`;
    }
    // app/newsletter.tsx
    "use client";
    
    import { useActionState } from "react";
    import { subscribe } from "./actions";
    
    export function Newsletter() {
      const [message, formAction, pending] = useActionState(subscribe, "");
      return (
        <form action={formAction}>
          <input name="name" aria-label="Name" placeholder="Name" />
          <button disabled={pending}>Subscribe</button>
          {message && <p>{message}</p>}
        </form>
      );
    }
    // app/page.tsx
    import { Newsletter } from "./newsletter";
    
    export default function Page() {
      return <Newsletter />;
    }

    Page

    [Name…] [Subscribe]
    
    › click “Subscribe”
    [Name…] [Subscribe]
    Please enter your name
    
    › type “Meera” into Name
    [Meera] [Subscribe]
    Please enter your name
    
    › click “Subscribe”
    [Name…] [Subscribe]
    Thanks, Meera!

Quick check

How does the browser call a Server Action?

Next.js sends a POST request with the action's encrypted ID and the arguments.

Should validation errors be thrown or returned?

Returned (as state for useActionState); throw only for unexpected errors.

Is a Server Action safe because only admins see its button?

No – always authenticate and authorise inside the action.

Summary

  • "use server" functions run on the server and can be passed to form actions and Client Components.
  • Validate FormData (zod), return expected errors to useActionState, and refresh or update cached data afterwards.
  • Actions are public POST endpoints: authenticate, authorise and validate in every one.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular