cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Forms in Next.js

Check a form on the server with Zod, show each field's error with useActionState, keep what visitors typed, and build search forms with next/form.

By the end, you can
  • 01
    Answer a form from the serverGive a Server Action to useActionState, check the fields with Zod, and show each error next to its field.
  • 02
    Keep the form usableKeep what the visitor typed after an error, with and without JavaScript, and show that the form is busy with useFormStatus.
  • 03
    Build search and filter formsUse next/form for forms that change the address, so they prefetch and navigate without a page load.

A sign-up form that answers back

The tea shop has a club: one new tea every month. To join, a visitor types a name and an email and clicks Join. A Server Action saves the new member: a function that runs on the server when the form is sent. (Your first Next.js app built one that saves a note.) But people make mistakes. They type one letter for a name, or half an email. The server has to answer: what was wrong, and in which box. Then the visitor fixes it without typing everything again.

Here is that form. It's a real Next.js 16.4.3 project, built with next build and run with next start. Then real Chrome used it: it typed A and asha@, and clicked Join. The Steps tab opens on that click.

The tea club's sign-up formNext.js
app/club/
app/club/actions.ts
"use server"; import { z } from "zod"; const member = z.object({  name: z.string().trim().min(2, "Write at least 2 letters."),  email: z.email("Write an email address, like asha@example.com."),}); export type JoinState = {  message: string;  errors: { name?: string[]; email?: string[] };  values: { name: string; email: string };}; export async function join(previous: JoinState, formData: FormData): Promise<JoinState> {  const values = {    name: String(formData.get("name") ?? ""),    email: String(formData.get("email") ?? ""),  };  const result = member.safeParse(values);  if (!result.success) {    const errors = z.flattenError(result.error).fieldErrors;    return { message: "Check the fields above.", errors, values };  }  // A real shop would save the new member here.  return { message: `Welcome to the club, ${result.data.name}!`, errors: {}, values: { name: "", email: "" } };}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/clubTea shop
Join the tea club

One new tea every month, with a letter about where it grows.

A

Write at least 2 letters.

asha@

Write an email address, like asha@example.com.

Join

Check the fields above.

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. POST/club200text/x-component
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22club%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
    next-action
    60edffa33aadbf415a8db4874924460d609967200a

After step 4, each box has a message under it: Write at least 2 letters. under Name, and Write an email address, like asha@example.com. under Email. Under the button is Check the fields above. The boxes still hold A and asha@. The click made one request, a POST to /club, and no page load. Pick step 9: after the visitor fixed both boxes, the form said Welcome to the club, Asha!

The form has four files, and each one does one job:

  • app/club/actions.ts, open above, is the Server Action. Lines 5 to 8 describe a good member with Zod, a library that checks values, and join returns what the form should show.
  • app/club/join-form.tsx is the form. It hands join to useActionState, a React hook, and shows what join returned.
  • app/club/join-button.tsx is the Join button. It knows when its form is being sent.
  • app/club/page.tsx is the page at /club. It shows a heading and the form.
Two windows. Left, the browser shows the club form after Join: Name A, the message Write at least 2 letters, Email asha@, and the Join button. An arrow labeled FormData: name, email goes right, to the server window, where join runs member.safeParse and makes errors and values. An arrow labeled state: errors, values goes back to the form.browserName: AWrite at least 2 letters.Email: asha@Jointhe form, in a Client ComponentFormData: name, emailstate: errors, valuesserverjoin(previous, …)member.safeParseflattenErrorerrors + valuesthe Server Action
Join sends the fields to join on the server. It checks them with Zod and returns a state with the errors and the values; useActionState hands that state to the form, which shows the messages. The email's message shows under its box too, left out here to save room.
Real-life analogyA form at the post office counter

At a good post office counter, a clerk checks your form before taking it. If a box is wrong, the clerk doesn't throw the form away. They hand it back with a note next to the wrong box, and everything else you wrote is still there. You fix one box and hand it over again.

In real life: You hand your filled-in form across the counter
In Next.js: Join sends the fields to the Server Action
In real life: The clerk checks every box against the rules
In Next.js: Zod checks the fields on the server
In real life: The clerk hands the same form back, with a note next to each wrong box
In Next.js: The action returns errors, and the form shows each one under its box
In real life: Everything you wrote is still on it
In Next.js: The boxes keep what the visitor typed
The main idea

The form sends its fields to a Server Action, and the action's answer becomes the form's state. The action checks every field on the server and returns what went wrong, with what the visitor typed. useActionState gives that answer to the form, which shows each message next to its box and puts the typed values back.

useActionState: the action's answer becomes the form's state

A Server Action given straight to a form, as in <form action={join}>, runs on the server, but the form never sees what it returns. To show the answer, the form needs somewhere to keep it. That's useActionState, from React. It wraps the action, and keeps what the action returned last as the form's state. (Form actions, in the React course, teaches the hook on its own. Here it's connected to a Server Action.)

app/club/join-form.tsx: the form keeps the action's answerTSX
"use client"; import { useActionState } from "react";import { join, type JoinState } from "./actions";import JoinButton from "./join-button"; const empty: JoinState = { message: "", errors: {}, values: { name: "", email: "" } }; export default function JoinForm() {  const [state, formAction] = useActionState(join, empty);  return (    <form action={formAction}>      <label htmlFor="name">Name</label>      <input id="name" name="name" defaultValue={state.values.name} aria-describedby="name-error" />      <p id="name-error">{state.errors.name?.[0]}</p>       <label htmlFor="email">Email</label>      <input id="email" name="email" defaultValue={state.values.email} aria-describedby="email-error" />      <p id="email-error">{state.errors.email?.[0]}</p>       <JoinButton />      <p aria-live="polite">{state.message}</p>    </form>  );} 

Line 1 makes the file a Client Component, because useActionState keeps state in the browser, so it needs a Client Component. Line 4 imports join from the actions file, and its type, JoinState. Line 7 is the state before anything was sent: no message, no errors, and empty values.

Line 10 is the hook. It takes two things and gives back three; this form uses the first two:

  • It takes the action, join, and the first state, empty.
  • It gives back state, which is empty at first and then whatever join returned last, and formAction, a new function that calls join for you.
  • The third, isPending, says whether join is still running. The club form leaves it out, and its button finds out another way, with useFormStatus.

Line 12 gives formAction to the form. When the visitor clicks Join, React collects the boxes into a FormData object and calls formAction. That sends them to join on the server, and when join answers, React draws the form again with the answer as state. The rest of the form reads state: lines 15 and 19 show the errors, and line 22 the message.

Now the action. Giving it to useActionState changes what it receives:

app/club/actions.ts: the Server Action gets the previous state firstTSX
"use server"; import { z } from "zod"; const member = z.object({  name: z.string().trim().min(2, "Write at least 2 letters."),  email: z.email("Write an email address, like asha@example.com."),}); export type JoinState = {  message: string;  errors: { name?: string[]; email?: string[] };  values: { name: string; email: string };}; export async function join(previous: JoinState, formData: FormData): Promise<JoinState> {  const values = {    name: String(formData.get("name") ?? ""),    email: String(formData.get("email") ?? ""),  };  const result = member.safeParse(values);  if (!result.success) {    const errors = z.flattenError(result.error).fieldErrors;    return { message: "Check the fields above.", errors, values };  }  // A real shop would save the new member here.  return { message: `Welcome to the club, ${result.data.name}!`, errors: {}, values: { name: "", email: "" } };} 

Line 1, "use server", makes every function this file exports a Server Action. On line 16, join takes previous first: the state the form has now. The fields come second, as formData. A Server Action that a form calls directly gets only formData; once useActionState calls it, the state comes first. join doesn't need previous, but it must still take it, so that formData is the second one.

Lines 10 to 14 describe the state, and join returns one on both paths: line 24 when something is wrong, and line 27 when everything is right. What it returns travels to the browser, so return only what the form needs to show.

The page around the form stays a Server Component. Line 8 of app/club/page.tsx renders <JoinForm />; only the form and the button are Client Components. The build printed ○ /club: the page, form included, was made once during the build, and the form's state lives in the browser.

Checking the fields on the server with Zod

A form's fields arrive as text, from someone you don't know. Before you save them, check each one. You could write the checks by hand, with an if for each rule. A schema is shorter: a description of what good data looks like, which a library compares the data with. Zod is the library in the Next.js docs' example, and its latest version on October 30, 2026 was 4.6.7. The example adds it to package.json, as npm install zod does.

In the action, lines 5 to 8 are the schema:

  • z.object(…) on line 5 says the data is an object with the keys listed inside.
  • Line 6 says name is a string. .trim() removes spaces at both ends first, and .min(2, "Write at least 2 letters.") needs at least 2 characters. The text is the message the visitor sees when the rule fails.
  • Line 7, z.email(…), needs a string shaped like an email address, with its own message.

Lines 17 to 20 read the two fields from formData. formData.get("name") gives the box named name, or null if there's none, and String(… ?? "") turns a missing box into an empty string. Line 21 asks Zod to check them with safeParse. It never throws: it returns an object whose success says whether the data passed. If it passed, result.data is the checked data, with the spaces trimmed, which line 27 uses for the name. If it failed, result.error lists every rule that failed.

Line 23 turns that list into one message list per field with z.flattenError, and keeps its fieldErrors. For A and asha@, Zod failed both rules, and the recording shows what came back:

state.errors after A and asha@, from the recordingJSON
{
  "name": ["Write at least 2 letters."],
  "email": ["Write an email address, like asha@example.com."]
}

Each field gets a list, because one value can break several rules. A field with no problem isn't in the object at all.

Older code: Zod 3

Zod 4 came out in July 2025. Code written for Zod 3, like the Forms guide on nextjs.org on October 30, 2026, writes result.error.flatten().fieldErrors. Zod 4 still has that method, but marks it deprecated: it may go away in a later version. z.flattenError(result.error) gives the same object. Zod 3's invalid_type_error option no longer does anything in Zod 4, which shows its own message instead. Zod 4 takes your message as the rule's last argument, as lines 6 and 7 do, or as { error: "…" }.

Why on the server

Browsers can check a form too. required on an input stops an empty box, and type="email" stops text without @, before anything is sent. Those checks are worth adding: the visitor gets an answer at once. But they only run in the browser, on your page.

A Server Action is a POST request to your server, and anyone can send one, with any fields, without your page. Only a check inside the action runs for every request. So the action is where the rules have to be; the browser's checks are a faster first answer on top. (Forms and inputs covers the browser's own checks.)

Showing each error next to its field

state.errors has a list for each field that failed. The form shows the first message of each list right under its box. Look at lines 13 to 15 of app/club/join-form.tsx again:

  • Line 13, <label htmlFor="name">, names the box. A click on the label puts the cursor in the box, and a screen reader reads Name when the box gets focus.
  • Line 15 shows state.errors.name?.[0], the first message for name. ?. stops at undefined when there's no list, so the paragraph is empty then.
  • On line 14, aria-describedby="name-error" points the box at that paragraph, by its id. A screen reader then reads the message with the box's name.

Lines 17 to 19 do the same for Email. The message on line 22 has aria-live="polite": a screen reader reads it out when it changes, after what it's reading now. (Accessible React explains both attributes.)

Each row is one form the recorder sent, what join returned, and what the form then showed. The first is the click in the Steps tab; the other two are in the Network tab, sent without JavaScript.

What the form sent, and what came back
What was typedWhat join returnedWhat the form showed
A and asha@errors.name and errors.email, each with one message, and values with A and asha@.Both messages under their fields, Check the fields above., and both boxes still filled in.
A and asha@example.comOnly errors.name. The email passed, so Zod lists no error for it.Write at least 2 letters. under Name, nothing under Email.
Asha and asha@example.comerrors is empty, values are empty, and message is Welcome to the club, Asha!.The welcome message, and two empty boxes.

Only the field that broke a rule gets a message. That's what makes field errors useful: the visitor looks at one box, not the whole form.

Keeping what the visitor typed

Why does join return values too? Because of what React does after an action. When a form's action finishes without throwing, React resets the form: it calls the browser's form.reset(), which puts every box back to its default value. Returning errors still counts as finishing. React doesn't look at what the action returned. The reset only changes boxes React doesn't control: a box whose value comes from state keeps it. The club's boxes have no value, so without help, a mistake empties the whole form.

The help is defaultValue. React updates each box's default first, and resets the form after. So if the default is what the visitor typed, the reset puts it back. That's what defaultValue={state.values.name} on line 14 does, with the values join returned on line 24. (Form actions shows the rule in React's own code.) Compare the two versions:

Playground: what is left in the boxes after an error?
The club form, with this version of app/club/join-form.tsxNext.js
app/club/
app/club/join-form.tsx
"use client"; import { useActionState } from "react";import { join, type JoinState } from "./actions";import JoinButton from "./join-button"; const empty: JoinState = { message: "", errors: {}, values: { name: "", email: "" } }; export default function JoinForm() {  const [state, formAction] = useActionState(join, empty);  return (    <form action={formAction}>      <label htmlFor="name">Name</label>      <input id="name" name="name" aria-describedby="name-error" />      <p id="name-error">{state.errors.name?.[0]}</p>       <label htmlFor="email">Email</label>      <input id="email" name="email" aria-describedby="email-error" />      <p id="email-error">{state.errors.email?.[0]}</p>       <JoinButton />      <p aria-live="polite">{state.message}</p>    </form>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/clubTea shop
Join the tea club

One new tea every month, with a letter about where it grows.

Write at least 2 letters.

Write an email address, like asha@example.com.

Join

Check the fields above.

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. POST/club200text/x-component
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22club%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
    next-action
    606a64da982561fb88b5727d8c814e92614c0a39ba
Try it yourself
No defaultValue

The boxes on lines 14 and 18 have no defaultValue. The messages came back, but both boxes are empty: React reset the form when the action finished, and each box went back to its default, which is empty.

Both versions were built with next build and used in real Chrome: open /club, type A and asha@, click Join. The Steps tab opens after the click. Only app/club/join-form.tsx changes. Reset goes back to the first version.

Without defaultValue, the messages arrive and the boxes are empty: the visitor has to type asha@ again to fix one letter. With it, the boxes hold A and asha@. After a success, line 27 returns empty values, so the reset empties the form, ready for the next person.

Before the JavaScript has loaded

A visitor on a slow phone may click Join before the page's JavaScript has arrived. Then React isn't running yet, and the browser sends the form the plain way: a POST that loads a new page. The form still works, because React wrote it so the browser can send it on its own. This is called progressive enhancement: the page works with HTML alone, and gets better once the JavaScript runs.

The recorder sent the form that way too, without any JavaScript. Open the Network tab and pick /club first:

The club form, sent without JavaScriptNext.js
app/club/
app/club/actions.ts
"use server"; import { z } from "zod"; const member = z.object({  name: z.string().trim().min(2, "Write at least 2 letters."),  email: z.email("Write an email address, like asha@example.com."),}); export type JoinState = {  message: string;  errors: { name?: string[]; email?: string[] };  values: { name: string; email: string };}; export async function join(previous: JoinState, formData: FormData): Promise<JoinState> {  const values = {    name: String(formData.get("name") ?? ""),    email: String(formData.get("email") ?? ""),  };  const result = member.safeParse(values);  if (!result.success) {    const errors = z.flattenError(result.error).fieldErrors;    return { message: "Check the fields above.", errors, values };  }  // A real shop would save the new member here.  return { message: `Welcome to the club, ${result.data.name}!`, errors: {}, values: { name: "", email: "" } };}
Recorded from Next.js 16.4.3

GET /club200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 16 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/2gsxhio73malr.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/0ooaq-girjaeh.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <script src="/_next/static/chunks/0am_g01o68zh_.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <main>
      <h1>Join the tea club</h1>
      <p>One new tea every month, with a letter about where it grows.</p>
      <form action="" enctype="multipart/form-data" method="POST">
        <input type="hidden" name="$ACTION_REF_1">
        <input type="hidden" name="$ACTION_1:0" value="{&quot;id&quot;:&quot;60edffa33aadbf415a8db4874924460d609967200a&quot;,&quot;bound&quot;:&quot;$@1&quot;}">
        <input type="hidden" name="$ACTION_1:1" value="[{&quot;message&quot;:&quot;&quot;,&quot;errors&quot;:{},&quot;values&quot;:{&quot;name&quot;:&quot;&quot;,&quot;email&quot;:&quot;&quot;}}]">
        <input type="hidden" name="$ACTION_KEY" value="kfb299cc11de108bcb378e0210cf0e99a">
        <label for="name">Name</label>
        <input id="name" aria-describedby="name-error" name="name" value="">
        <p id="name-error"></p>
        <label for="email">Email</label>
        <input id="email" aria-describedby="email-error" name="email" value="">
        <p id="email-error"></p>
        <button>Join</button>
        <p aria-live="polite"></p>
      </form>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

In the HTML of /club, the form is <form action="" enctype="multipart/form-data" method="POST">: it posts to the page it's on. Inside it, before the boxes, are hidden fields that React wrote, which the browser sends along with the boxes. They name the action, and carry the form's current state. So the state the action returns ends up in the HTML too: one more reason to return only what the form shows.

Now pick POST /club, without JavaScript: A and asha@. The answer is 200, and a whole new HTML page. Next.js ran join, then rendered the page with the state it returned. The boxes are value="A" and value="asha@", both messages are there, and so is Check the fields above. The same defaultValue did it: on the server, a default value is the value in the HTML. Pick without JavaScript: Asha: the welcome message, and empty boxes.

Two rows of three cards. With JavaScript: POST with a next-action header, then a 200 RSC payload with the state, then React sets the box's defaultValue to A and resets the form to it. Without JavaScript: the browser posts the form, the server runs join and renders the page, and the new HTML already has value A in the box.With JavaScriptPOST, next-actionReact sends the fields200 RSC payloadjoin returns the statedefaultValue: AReact resets to itWithout JavaScriptPOST the formthe browser sends them200 HTML pagejoin, then the pagevalue="A"already in the HTML
The same join runs both times. With JavaScript, the state comes back as data, and React resets each box to its new defaultValue. Without it, Next.js renders the page with the state, so the HTML already holds what the visitor typed.

With JavaScript running, the trip is different, and the Steps tab above showed it: one fetch, a POST to /club with a next-action header that names the action, answered 200 with text/x-component. That answer is an RSC payload: data that React reads, holding what the action returned. React put it in state and drew the form again, with no new page. A redirect from an action works in a similar way; Redirects shows a 303 without JavaScript and a client-side move with it.

A Join button that knows the form is busy

On a slow network, a visitor clicks Join and nothing seems to happen. They click again, and send the form twice. The button should say that the form is on its way, and refuse a second click. For that, it needs to know whether its form is being sent. useFormStatus, from react-dom, tells it:

app/club/join-button.tsx: the button reads its form's statusTSX
"use client"; import { useFormStatus } from "react-dom"; export default function JoinButton() {  const { pending } = useFormStatus();  return <button disabled={pending}>{pending ? "Joining…" : "Join"}</button>;} 

Line 3 imports the hook from react-dom, not react. Line 6 reads pending: true from the click until the action has answered. Line 7 turns the button off with disabled while it's true, and changes its text to Joining….

useFormStatus reports on the form around the component that calls it. So the button has to be its own component, rendered inside the <form>, as line 21 of join-form.tsx does. Called in the component that renders the form, it has no form around it. Both versions below were recorded on a slow network: Chrome held every request back before sending it, the way a weak phone signal does. Pick each one and look at step 8, the click on Join:

Playground: where does useFormStatus go?
The club form, with this version of app/club/join-form.tsxNext.js
app/club/
app/club/join-form.tsx
"use client"; import { useActionState } from "react";import { useFormStatus } from "react-dom";import { join, type JoinState } from "./actions"; const empty: JoinState = { message: "", errors: {}, values: { name: "", email: "" } }; export default function JoinForm() {  const [state, formAction] = useActionState(join, empty);  const { pending } = useFormStatus();  return (    <form action={formAction}>      <label htmlFor="name">Name</label>      <input id="name" name="name" defaultValue={state.values.name} aria-describedby="name-error" />      <p id="name-error">{state.errors.name?.[0]}</p>       <label htmlFor="email">Email</label>      <input id="email" name="email" defaultValue={state.values.email} aria-describedby="email-error" />      <p id="email-error">{state.errors.email?.[0]}</p>       <button disabled={pending}>{pending ? "Joining…" : "Join"}</button>      <p aria-live="polite">{state.message}</p>    </form>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/clubTea shop
Join the tea club

One new tea every month, with a letter about where it grows.

Join

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. GET/club200a whole page
Try it yourself
In the form's component

JoinForm calls useFormStatus on line 11, but it renders the form, so it isn't inside one. pending stays false: after the click, while the request was on its way, the button still said Join.

Both versions were built with next build and used in real Chrome on a slow network: every request waited before it went out. Pick the step that clicks Join to see the page while the request was on its way. Reset goes back to the first version.

Called in JoinForm, which renders the form, pending stayed false: while the request was on its way, the button said Join. In JoinButton, it said Joining…, and Chrome showed it turned off. Step 9, in both, is the welcome message once the answer arrived.

useFormStatus also gives data (the FormData being sent), method and action, so a button could say Joining as Asha…. It works for any form, whatever its action. A form that already calls useActionState has a third way: the hook's third value, isPending. Pending and optimistic UI compares the ways to know an action is running.

Search forms with next/form

Not every form changes data. A search box only asks for a list. Its answer belongs in the address, like /teas?q=green, so the visitor can share it, bookmark it, or go Back to it. The part after ? is the address's search params, which a page reads with its searchParams prop (Search params teaches it).

HTML already has such a form: with no method, a <form action="/teas"> sends a GET request, and puts every named field into the address. But the browser loads a whole new page for it. Next.js has its own form for this, Form, from next/form. Give it a string action, and it does three more things:

  • Client-side navigation. On submit, Next.js's router moves to the new address without a page load, as a Link does (Linking and navigating).
  • Prefetching. When the form comes on the screen, the router already fetches the target page's static shell: its layouts and its loading UI.
  • Progressive enhancement. Its HTML is a plain <form action="/teas">, so before the JavaScript has loaded, the browser sends it the ordinary way.
app/page.tsx: a search form made with next/formTSX
import Form from "next/form"; export default function Home() {  return (    <main>      <h1>Tea shop</h1>      <Form action="/teas">        <label htmlFor="q">Search teas</label> <input id="q" name="q" />{" "}        <label htmlFor="sort">Sort by</label>{" "}        <select id="sort" name="sort" defaultValue="name">          <option value="name">Name</option>          <option value="price">Price</option>        </select>{" "}        <button>Search</button>      </Form>    </main>  );} 

Line 1 imports Form. Line 7 opens it with action="/teas", the page to go to. The page stays a Server Component: Form brings its own browser code. Line 8 is the box named q, and line 10 a menu named sort, which starts on name. Every named field becomes a search param, so the menu is a filter with no extra code. app/teas/page.tsx reads them with await searchParams, in a part wrapped in Suspense with Loading teas… as its fallback. Now compare it with a plain <form>:

Playground: a plain form or next/form?
The tea shop, with this version of app/page.tsxNext.js
app/teas/
app/page.tsx
export default function Home() {  return (    <main>      <h1>Tea shop</h1>      <form action="/teas">        <label htmlFor="q">Search teas</label> <input id="q" name="q" />{" "}        <label htmlFor="sort">Sort by</label>{" "}        <select id="sort" name="sort" defaultValue="name">          <option value="name">Name</option>          <option value="price">Price</option>        </select>{" "}        <button>Search</button>      </form>    </main>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas?q=green&sort=nameTea shop
Search results
  • Green tea: $4
  • Jasmine green tea: $5
Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. GET/teas?q=green&sort=name200a whole page
Try it yourself
A plain <form>

Opening / fetched nothing else. Search made a document request: the browser loaded /teas?q=green&sort=name as a whole new page.

Both versions were built with next build and used in real Chrome: open /, type green, make the network slow, click Search. The Steps tab opens after the click; step 1 lists what opening the page fetched. Only app/page.tsx changes. Reset goes back to the first version.

Both ended at the same address, /teas?q=green&sort=name, with the same two teas. The trips there differ. The plain form made a document request: a whole new page. The next/form version made no page load. Opening / had already fetched two parts of /teas, with the next-router-prefetch header, because the form was on the screen. Search then made one fetch with the rsc header, for the rest of the page. On the slow network, the address changed right after the click, and Search results and Loading teas… showed while that request was on its way: they came from the shell fetched before.

A browser window at slash shows the search form: Search teas green, Sort by Name, and a Search button. One arrow goes to a tag, /teas shell, fetched as soon as the form is on the screen. Another goes to a card, /teas?q=green&sort=name, fetched when Search is clicked, without a page load./Search teas: greenSort by: NameSearch/teasshellbefore the click/teas?q=green&sort=nameon Search, no page load
<Form action="/teas"> fetches the static shell of /teas as soon as it's on the screen. On Search, the fields become the address's search params, and the router fetches only the rest of the new page.

Each row compares the two forms, as recorded:

A plain form and next/form
QuestionA plain <form><Form> from next/form
What happens when it's submitted?The browser loads a whole new page.Next.js's router moves to the new address and swaps the new page in. No page load.
What is fetched before the click?Nothing.The static shell of /teas, as soon as the form is on the screen.
What did the click on Search request?A document: the whole page at /teas?q=green&sort=name.One fetch with the rsc header, for the parts of the page the shell didn't have.
Does it work before JavaScript loads?Yes. It's an ordinary form.Yes. It renders as an ordinary <form>, so the browser sends it the plain way.

Form also takes replace (change the current history entry instead of adding one), scroll and prefetch, like Link. It doesn't take method, encType or target, because they would turn its own behavior off; use a plain <form> for those. Given a function instead of a string, Form acts like React's <form> and calls it as a Server Action, without prefetching, since the action decides where to go.

Which tool does each form need?

A shop has two kinds of forms. One kind changes data and answers on the same page. The other finds data, and its answer is another address. Each row asks one question of both:

Two kinds of forms
QuestionA form that changes dataA search or filter form
What does it do?Changes data: joins the club, posts a review, saves a setting.Finds data: searches teas, sorts or filters a list.
Where does it send the fields?To a Server Action, as a POST request.Into the address, as search params: /teas?q=green.
What do you write?A <form action={formAction}> with useActionState, and the checks in the action.<Form action="/teas"> from next/form, and a page that reads searchParams.
Where does the answer go?Into the form's state, so the same page shows it.Into a new page, at the new address, which the visitor can share or bookmark.

A form that changes data often uses three tools at once: Zod in the action, useActionState in the form, and useFormStatus in its button. Sort each job into the tool that does it:

Which tool does this job?
  • Show Write at least 2 letters. under the Name box.
  • Keep the name the visitor typed after an error.
  • A Join button, in its own component, that says Joining… while its form is sent.
  • A Save button file that turns itself off while any form it's placed in is sent.
  • Refuse an email without @, even from a request sent without the form.
  • Refuse 12 cups a day when the limit is 10.
  • A Search box that opens /teas?q=green.
  • A Sort by menu whose choice ends up in the address as sort=price.
Try it yourself
0 of 8 correct

Sort each job into the tool that does it in the tea shop's forms.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The browser's checks are enough." No. required and type="email" only run on your page. A Server Action is a POST anyone can send, so it checks every field itself.
  • "React only resets the form when the action succeeds." No. It resets after any action that finishes without throwing, even one that returns errors. Give each box its defaultValue from the state.
  • "Errors should be thrown." No. A wrong field is an expected problem: return it in the state, and the form shows it. A thrown error goes to error.tsx instead (Error handling).
  • "useFormStatus works anywhere in the form's component." No. It reads the form around the component that calls it. Call it in a component rendered inside the form.
  • "A form with useActionState needs JavaScript." No. With a Server Action, the browser can send it before the JavaScript loads, and Next.js answers with the page rendered from the new state.
  • "next/form is for every form." No. Its extras (prefetching, the client-side navigation) are for a string action: a search or filter. For a form that changes data, a Server Action does the work.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page.

Exercise 1 · Warm-upPredict which box gets a message

A visitor types A as the name and asha@example.com as the email, and clicks Join. Which field gets a message?

Starter codeTSX
"use server";

import { z } from "zod";

const member = z.object({
  name: z.string().trim().min(2, "Write at least 2 letters."),
  email: z.email("Write an email address, like asha@example.com."),
});

export type JoinState = {
  message: string;
  errors: { name?: string[]; email?: string[] };
  values: { name: string; email: string };
};

export async function join(previous: JoinState, formData: FormData): Promise<JoinState> {
  const values = {
    name: String(formData.get("name") ?? ""),
    email: String(formData.get("email") ?? ""),
  };
  const result = member.safeParse(values);
  if (!result.success) {
    const errors = z.flattenError(result.error).fieldErrors;
    return { message: "Check the fields above.", errors, values };
  }
  // A real shop would save the new member here.
  return { message: `Welcome to the club, ${result.data.name}!`, errors: {}, values: { name: "", email: "" } };
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the search address

    The visitor types green into Search teas, leaves Sort by as it is, and clicks Search. Which address does the browser end up at?

    Starter codeTSX
    import Form from "next/form";
    
    export default function Home() {
      return (
        <main>
          <h1>Tea shop</h1>
          <Form action="/teas">
            <label htmlFor="q">Search teas</label> <input id="q" name="q" />{" "}
            <label htmlFor="sort">Sort by</label>{" "}
            <select id="sort" name="sort" defaultValue="name">
              <option value="name">Name</option>
              <option value="price">Price</option>
            </select>{" "}
            <button>Search</button>
          </Form>
        </main>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind why the button never says Joining…

      On a slow network, this form's button keeps saying Join while the form is sent. Which line has to move, and where to?

      Starter codeTSX
      "use client";
      
      import { useActionState } from "react";
      import { useFormStatus } from "react-dom";
      import { join, type JoinState } from "./actions";
      
      const empty: JoinState = { message: "", errors: {}, values: { name: "", email: "" } };
      
      export default function JoinForm() {
        const [state, formAction] = useActionState(join, empty);
        const { pending } = useFormStatus();
        return (
          <form action={formAction}>
            <label htmlFor="name">Name</label>
            <input id="name" name="name" defaultValue={state.values.name} aria-describedby="name-error" />
            <p id="name-error">{state.errors.name?.[0]}</p>
      
            <label htmlFor="email">Email</label>
            <input id="email" name="email" defaultValue={state.values.email} aria-describedby="email-error" />
            <p id="email-error">{state.errors.email?.[0]}</p>
      
            <button disabled={pending}>{pending ? "Joining…" : "Join"}</button>
            <p aria-live="polite">{state.message}</p>
          </form>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeBring back what the visitor typed

        After an error, this form shows the messages, but both boxes are empty. Change the form so they keep what was typed.

        Starter codeTSX
        "use client";
        
        import { useActionState } from "react";
        import { join, type JoinState } from "./actions";
        import JoinButton from "./join-button";
        
        const empty: JoinState = { message: "", errors: {}, values: { name: "", email: "" } };
        
        export default function JoinForm() {
          const [state, formAction] = useActionState(join, empty);
          return (
            <form action={formAction}>
              <label htmlFor="name">Name</label>
              <input id="name" name="name" aria-describedby="name-error" />
              <p id="name-error">{state.errors.name?.[0]}</p>
        
              <label htmlFor="email">Email</label>
              <input id="email" name="email" aria-describedby="email-error" />
              <p id="email-error">{state.errors.email?.[0]}</p>
        
              <JoinButton />
              <p aria-live="polite">{state.message}</p>
            </form>
          );
        }
        
          Exercise 5 · ChallengeAdd a Cups a day field

          The club wants to know how many cups of tea a day each member drinks: a whole number from 1 to 10, with the message Choose from 1 to 10 cups. Change the action. What does the form show under the new box when the visitor types 12?

          Starter codeTSX
          "use server";
          
          import { z } from "zod";
          
          const member = z.object({
            name: z.string().trim().min(2, "Write at least 2 letters."),
            email: z.email("Write an email address, like asha@example.com."),
          });
          
          export type JoinState = {
            message: string;
            errors: { name?: string[]; email?: string[] };
            values: { name: string; email: string };
          };
          
          export async function join(previous: JoinState, formData: FormData): Promise<JoinState> {
            const values = {
              name: String(formData.get("name") ?? ""),
              email: String(formData.get("email") ?? ""),
            };
            const result = member.safeParse(values);
            if (!result.success) {
              const errors = z.flattenError(result.error).fieldErrors;
              return { message: "Check the fields above.", errors, values };
            }
            // A real shop would save the new member here.
            return { message: `Welcome to the club, ${result.data.name}!`, errors: {}, values: { name: "", email: "" } };
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeMake the search a client-side navigation

            This search form makes the browser load a whole new page. What do you import to make it a client-side navigation that prefetches /teas? Change the page.

            Starter codeTSX
            export default function Home() {
              return (
                <main>
                  <h1>Tea shop</h1>
                  <form action="/teas">
                    <label htmlFor="q">Search teas</label> <input id="q" name="q" />{" "}
                    <label htmlFor="sort">Sort by</label>{" "}
                    <select id="sort" name="sort" defaultValue="name">
                      <option value="name">Name</option>
                      <option value="price">Price</option>
                    </select>{" "}
                    <button>Search</button>
                  </form>
                </main>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, think about where the code runs, and what travels between the browser and the server.

              The Forms in Next.js quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Which component calls useActionState for the club form?

                Choose an answer to see the explanation.

              2. Question 2 of 7When useActionState calls this action, what is previous?

                Read the code, then predictTSX
                "use server";
                
                import { z } from "zod";
                
                const member = z.object({
                  name: z.string().trim().min(2, "Write at least 2 letters."),
                  email: z.email("Write an email address, like asha@example.com."),
                });
                
                export type JoinState = {
                  message: string;
                  errors: { name?: string[]; email?: string[] };
                  values: { name: string; email: string };
                };
                
                export async function join(previous: JoinState, formData: FormData): Promise<JoinState> {
                  const values = {
                    name: String(formData.get("name") ?? ""),
                    email: String(formData.get("email") ?? ""),
                  };
                  const result = member.safeParse(values);
                  if (!result.success) {
                    const errors = z.flattenError(result.error).fieldErrors;
                    return { message: "Check the fields above.", errors, values };
                  }
                  // A real shop would save the new member here.
                  return { message: `Welcome to the club, ${result.data.name}!`, errors: {}, values: { name: "", email: "" } };
                }
                

                Choose an answer to see the explanation.

              3. Question 3 of 7The action returned errors, and the form has no defaultValue on its boxes. Why are the boxes empty afterwards?

                Choose an answer to see the explanation.

              4. Question 4 of 7The page's JavaScript hasn't loaded yet. The visitor sends the club form with mistakes. What comes back?

                Choose an answer to see the explanation.

              5. Question 5 of 7This form calls useFormStatus on line 11. What does its button show while the action runs?

                Read the code, then predictTSX
                "use client";
                
                import { useActionState } from "react";
                import { useFormStatus } from "react-dom";
                import { join, type JoinState } from "./actions";
                
                const empty: JoinState = { message: "", errors: {}, values: { name: "", email: "" } };
                
                export default function JoinForm() {
                  const [state, formAction] = useActionState(join, empty);
                  const { pending } = useFormStatus();
                  return (
                    <form action={formAction}>
                      <label htmlFor="name">Name</label>
                      <input id="name" name="name" defaultValue={state.values.name} aria-describedby="name-error" />
                      <p id="name-error">{state.errors.name?.[0]}</p>
                
                      <label htmlFor="email">Email</label>
                      <input id="email" name="email" defaultValue={state.values.email} aria-describedby="email-error" />
                      <p id="email-error">{state.errors.email?.[0]}</p>
                
                      <button disabled={pending}>{pending ? "Joining…" : "Join"}</button>
                      <p aria-live="polite">{state.message}</p>
                    </form>
                  );
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7<Form action="/teas"> from next/form holds a box named q. What happens when the visitor submits green?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why check the fields with Zod inside the action, even if the inputs have required and type="email"?

                Choose an answer to see the explanation.

              Key takeaways

              • useActionState(action, firstState), in a Client Component, gives back state, formAction and isPending. The form calls formAction, and what the Server Action returns becomes state.
              • Called through useActionState, a Server Action gets the previous state first and the FormData second.
              • Check every field inside the action, with a schema like Zod's and safeParse. Return z.flattenError(error).fieldErrors and show each field's first message under its box.
              • React resets a form after any action that finishes without throwing. Return the typed values, and use them as each box's defaultValue; it works without JavaScript too, where Next.js renders the page from the new state.
              • useFormStatus reports on the form around the component that calls it, so put it in a button rendered inside the form.
              • For search and filters, Form from next/form puts the fields in the address, prefetches the target's shell, and moves there without a page load.

              Remember the one-liner.
              The action checks and answers; the form shows the answer and keeps what was typed.

              Coming next: Pending and optimistic UI. It shows progress while an action runs, and the result before the server answers, rolling back if it fails.

              CompleteFrontend Clear concepts. Working examples.