Server Actions and Route Handlers · Lesson 14 of 24
Next.js Server Actions
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
useActionStateand 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>
);
}
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
- Submitthe browser POSTs the form data to the page with an encrypted action ID
- RunNext.js calls sign(formData) on the server
- Re-renderrefresh() (or updateTag/revalidatePath) re-renders the affected UI
- 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>
);
}
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>
);
}
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
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 />; }[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.