Next.js App Router in Production · Rendering, Caching and Mutations
Server Actions and Forms with Validation
Handle form submissions with Server Actions: validate with zod, return field errors with useActionState, update cached data with updateTag, and work without JavaScript.
What you'll build
A guestbook form handled by a Server Action: input is validated with zod, field errors come back through useActionState, and a successful entry is saved and shown immediately with updateTag. The requests submit the form the way a browser does before (or without) JavaScript, so the form works with progressive enhancement.
The idea
- A function in a
"use server"module becomes a Server Action: a POST endpoint that forms (<form action={...}>) and client code can call. - Server Actions are public endpoints: validate every input and check authentication and authorization inside the action (see the security lesson).
useActionState(action, initialState)gives the form the action's last return value (field errors, success message) and a pending flag.- After a mutation,
updateTagexpires cached data so the response shows the change.
The code
// file: next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
export default nextConfig;
// file: app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><main>{children}</main></body>
</html>
);
}
// file: app/lib/entries.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
const store = globalThis as unknown as { entries?: { name: string; message: string }[] };
store.entries ??= [{ name: "Ana", message: "Lovely shop!" }];
export const entries = store.entries;
export async function getEntries() {
"use cache";
cacheLife("days");
cacheTag("entries");
return [...entries];
}
// file: app/actions.ts
"use server";
import { z } from "zod";
import { updateTag } from "next/cache";
import { entries } from "./lib/entries";
const Entry = z.object({
name: z.string().trim().min(2, "Name must have at least 2 characters"),
message: z.string().trim().min(1, "Write a message").max(200, "Keep it under 200 characters"),
});
export type FormState = { errors?: Record<string, string[]>; ok?: string };
export async function sign(_prev: FormState, formData: FormData): Promise<FormState> {
const parsed = Entry.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors };
entries.push(parsed.data);
updateTag("entries");
return { ok: `Thanks, ${parsed.data.name}!` };
}
// file: app/guestbook-form.tsx
"use client";
import { useActionState } from "react";
import { sign, type FormState } from "./actions";
export function GuestbookForm() {
const [state, action, pending] = useActionState<FormState, FormData>(sign, {});
return (
<form action={action}>
<input name="name" aria-label="Name" />
{state.errors?.name && <p role="alert">{state.errors.name[0]}</p>}
<input name="message" aria-label="Message" />
{state.errors?.message && <p role="alert">{state.errors.message[0]}</p>}
<button disabled={pending}>Sign</button>
{state.ok && <p>{state.ok}</p>}
</form>
);
}
// file: app/page.tsx
import { getEntries } from "./lib/entries";
import { GuestbookForm } from "./guestbook-form";
export default async function Page() {
const list = await getEntries();
return (
<>
<h1>Guestbook</h1>
<ul>{list.map((e, i) => <li key={i}>{e.name}: {e.message}</li>)}</ul>
<GuestbookForm />
</>
);
}
// request: SUBMIT / name=B&message=
// request: SUBMIT / name=Ben&message=Great teapots
// request: GET /Output
SUBMIT / name=B&message= -> 200 Guestbook Ana: Lovely shop! Name must have at least 2 characters Write a message Sign SUBMIT / name=Ben&message=Great teapots -> 200 Guestbook Ana: Lovely shop! Ben: Great teapots Sign Thanks, Ben! GET / -> 200 Guestbook Ana: Lovely shop! Ben: Great teapots Sign
Run this on your computer — see “Get Started” for setup.
The first submission fails validation and the page comes back with field errors. The second saves the entry; updateTag("entries") means the response already lists it.
Common mistakes
- Trusting form data because the form "only has these fields"; anyone can POST anything to an action.
- Throwing errors for expected validation failures instead of returning them as state.
- Forgetting to invalidate cached data after a mutation, so users don't see their change.
- Putting slow side effects (emails, webhooks) inside the action's critical path; use
after()or a queue.
Key points
- Server Actions are POST endpoints: validate and authorize inside each one.
- Return expected errors as state with useActionState.
- updateTag after mutations for read-your-own-writes; forms work without JavaScript.
Further reading: Mutating data · Forms · Server Actions security
Practice questions
Why must Server Actions validate and authorize their input?
Answer
They're reachable by direct POST requests, not just through your UI, so any caller can send any values.
What does useActionState provide?
Answer
The action's latest returned state (such as field errors), a wrapped action to pass to the form, and a pending flag while it runs.
How do forms with Server Actions work without JavaScript?
Answer
The form posts to the page with hidden fields identifying the action; the server runs it and responds with the re-rendered page (or a redirect).
Open in the app to mark lessons complete, take notes and track your progress.