Next.jsBeginner10 min readUpdated

Build a Form in Next.js With Server Actions and Zod

By Mudassir Khan — Agentic AI Consultant & AI Systems Architect, Islamabad, Pakistan

Cover illustration for: Build a Form in Next.js With Server Actions and Zod

Section 01 · The setup

What we are building, and why this stack

A contact form. Three fields, server side validation, friendly error messages, no separate API route.

Quick answer

How do you build a form in Next.js? Use a Server Action. Write an async function marked 'use server' that accepts FormData. Validate the data with Zod. Return errors or a success flag. In the Client Component, pass the action to the form's action prop and use the useActionState hook to read errors and pending state. No fetch call, no API route, no extra library for state management.

Older Next.js tutorials wired forms by writing an API route at app/api/contact/route.ts, then calling fetch from the client. That still works. It just costs you a separate file, manual loading state, manual error handling, and a CSRF strategy of your own.

Server Actions collapse the round trip into a normal function call. You write the function, mark it 'use server', and hand it to a form. Next.js handles the encoding, the network call, and the response. It also handles progressive enhancement: if JavaScript fails to load, the form still submits because the browser submits the form natively.

Form data flow showing user submit, Server Action runs, Zod validates, response returns errors or success.
One round trip. The browser submits, the action validates, the same action returns errors or a success flag.

Section 02 · Dependencies

Install Zod (the one extra dependency)

Server Actions and useActionState are built into Next.js. The only thing to install is Zod for validation.

bash
npm install zod

If you do not have a Next.js project yet, build one in five minutes with the installation guide. Make sure you picked App Router during the setup; Server Actions do not exist on the old Pages Router.

Section 03 · Schema

Define the form shape with Zod

One declarative schema gives you typed data, runtime validation, and error messages all in one go.

Create app/contact/page.tsx. At the top of the file (outside the component), declare the schema:

tsx
import { z } from "zod";

const ContactSchema = z.object({
  name: z.string().min(2, "Name must be at least two characters."),
  email: z.string().email("Enter a valid email address."),
  message: z.string().min(10, "Message must be at least ten characters."),
});

Each line is a rule plus the error message the user sees if the rule fails. Zod throws the messages back as part of the parse result, so you do not have to write the "this field is required" strings twice.

One schema, two uses

The same Zod schema also gives you a TypeScript type for the data via z.infer<typeof ContactSchema>. So you validate once and you get strict typing for free everywhere you handle that data inside the action.

Section 04 · The Server Action

Write the action that runs on submit

An async function marked 'use server'. It receives the form's previous state and the FormData object.

Below the schema, add the action. The shape matches what useActionState expects: a function that takes (prevState, formData) and returns a new state object.

tsx
"use server";

type FormState = {
  ok: boolean;
  errors?: Record<string, string>;
  values?: Record<string, string>;
};

export async function submitContact(
  _prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  const raw = {
    name: String(formData.get("name") ?? ""),
    email: String(formData.get("email") ?? ""),
    message: String(formData.get("message") ?? ""),
  };

  const parsed = ContactSchema.safeParse(raw);
  if (!parsed.success) {
    const errors: Record<string, string> = {};
    for (const issue of parsed.error.issues) {
      errors[issue.path[0] as string] = issue.message;
    }
    return { ok: false, errors, values: raw };
  }

  // Here is where you save to a database, send an email, queue a job, etc.
  // For this example we just log on the server.
  console.log("New contact:", parsed.data);

  return { ok: true };
}

A few things worth noting. The "use server" directive at the top is what tells Next.js this code runs on the server. The function is async because Server Actions almost always do an async database call or an email send. We return the user's submitted values on validation failure so the form can refill them; we do not return them on success because the form will be cleared anyway.

Section 05 · The form

Wire the form with useActionState

React 19's useActionState hook keeps the action wired to the form and exposes pending and error state.

Below the action, write the component. Mark the file "use client" so the hook works. (For a form that does not need any client side hooks at all, you can skip "use client" and call the action directly, but useActionState gives you pending state for free, which is worth the small cost.)

tsx
"use client";

import { useActionState } from "react";

const initial: FormState = { ok: false };

export default function ContactPage() {
  const [state, formAction, pending] = useActionState(submitContact, initial);

  if (state.ok) {
    return (
      <main className="mx-auto mt-20 max-w-md text-center">
        <h1 className="text-2xl font-semibold text-slate-900">Thanks, we got your message.</h1>
        <p className="mt-3 text-slate-600">We will reply within one business day.</p>
      </main>
    );
  }

  return (
    <main className="mx-auto mt-12 max-w-md px-4">
      <h1 className="text-2xl font-semibold text-slate-900">Get in touch</h1>
      <form action={formAction} className="mt-6 space-y-5">
        <Field
          label="Your name"
          name="name"
          defaultValue={state.values?.name}
          error={state.errors?.name}
        />
        <Field
          label="Email"
          name="email"
          type="email"
          defaultValue={state.values?.email}
          error={state.errors?.email}
        />
        <Field
          label="Message"
          name="message"
          textarea
          defaultValue={state.values?.message}
          error={state.errors?.message}
        />
        <button
          type="submit"
          disabled={pending}
          className="w-full rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:opacity-50"
        >
          {pending ? "Sending…" : "Send message"}
        </button>
      </form>
    </main>
  );
}

function Field({
  label, name, type = "text", textarea, defaultValue, error,
}: {
  label: string;
  name: string;
  type?: string;
  textarea?: boolean;
  defaultValue?: string;
  error?: string;
}) {
  const cls =
    "w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200";
  return (
    <label className="block">
      <span className="mb-1 block text-sm font-medium text-slate-700">{label}</span>
      {textarea ? (
        <textarea name={name} rows={5} defaultValue={defaultValue} className={cls} />
      ) : (
        <input name={name} type={type} defaultValue={defaultValue} className={cls} />
      )}
      {error ? <span className="mt-1 block text-sm text-rose-600">{error}</span> : null}
    </label>
  );
}

That is the entire form. Three field inputs, a submit button that disables itself while the request is in flight, per field error messages, and a success view. The whole file is under one hundred lines.

Section 06 · Test it

Try every path

Three submissions cover every code path in the form.

Submit with empty fields

You should see three red error messages, one under each field. The values you typed (in this case nothing) are preserved. Pending should be false again.

Submit with a fake email like 'foo@'

Only the email field shows an error. The other two values are preserved across the round trip because the action returns state.values on failure.

Submit a valid form

The page swaps to the success view. Open your terminal where npm run dev is running and you will see the console.log output from the Server Action. That is the proof the action ran on the server.

Why pending works without a manual loading flag

useActionState exposes the pending state of the action automatically. You do not need a separate useState. The hook flips pending to true the moment the form submits and back to false the moment the action returns.

Section 07 · The other approach

When to use react-hook-form instead

Server Actions cover most forms. There is one case where react-hook-form earns its keep.

Server Actions are the right default. They are simple, progressively enhanced, and ship less JavaScript. There is one case where react-hook-form is still worth reaching for: forms with rich client side validation that needs to fire on every keystroke (a password strength meter, a real time username availability check, a multi step wizard with conditional fields).

For a typical contact form, signup form, or settings page, Server Actions plus Zod is the full answer. Reach for react-hook-form when the form's complexity is the feature, not the plumbing.

Section 08 · Next steps

What to build next

You now know the Next.js basics end to end: install, structure, state, and forms.

You are at the end of the four post beginner series. The realistic next steps are: deploy this app to Vercel (one command, npx vercel), add a real database (Supabase, Postgres, PlanetScale all have first class Next.js guides), and start reading the official Next.js docs for the topics this series did not cover (caching, fetching, middleware, image optimisation).

If you ever need to refresh on the basics, the chain loops back to installation, folder structure, and the calculator project. For production grade Next.js apps (typed Server Actions, queueing, observability, agentic AI features), see the agentic AI consulting service.

Section 09 · Questions

Frequently asked questions

Common stumbles when wiring forms with Server Actions.

Can the action be in a different file from the component?

Yes, and it is the recommended pattern once the action grows. Put the schema and action in app/contact/actions.ts (with 'use server' at the top of that file), then import submitContact in page.tsx. The Server Action then becomes a normal import.

Do I still need to handle CSRF?

No. Next.js generates an action ID that is unique per build and validates it on submission. The request only fires the action if the ID matches. There is no token you have to plumb yourself.

What if I need to redirect after success?

Use redirect from next/navigation inside the Server Action: import { redirect } from 'next/navigation'; redirect('/thanks'). Call it after your database write succeeds. The redirect happens server side; the browser never sees the success state.

Can I upload a file with this pattern?

Yes. Add an input type='file' name='attachment' to the form. The FormData object on the server will have it as a File. Pipe it to Vercel Blob, S3, or any other storage. The action runs on the server so it has full access to credentials you keep in env vars.

Why does the action sometimes fail with 'Cannot read properties of undefined'?

Almost always because you forgot to mark the file or function 'use server' and Next.js is trying to run it in the browser. Add the directive at the very top of the file (or the very top of an exported function) and the error disappears.

Written by Mudassir Khan

Agentic AI consultant and AI systems architect based in Islamabad, Pakistan. CEO of Cube A Cloud. 38+ agentic AI launches delivered for global founders and CTOs.

View agentic AI consulting service

Related service

Agentic AI Consulting

See scope & pricing →

More on this topic

Need an AI systems architect?

Book a 30-minute architecture call. I will sketch the high-level design for your use case and give you an honest view of the trade-offs.

Book a strategy call →