How to Build a Next.js Form: Server Actions, Zod & useActionState
Build a Next.js form with no backend code. Updated for Next.js 15 and the App Router — Server Actions, Zod validation, useActionState, accessible inline errors, plus a legacy Pages Router example.
This guide shows you how to build a Next.js contact form that collects submissions without writing or hosting any backend code. It’s updated for Next.js 15 and the App Router, the default for new projects, and uses the patterns Next.js recommends today: a Server Action to handle the submission, Zod for validation, and useActionState for inline errors and a pending state. The form posts to a FormBackend endpoint, which stores every submission and emails you when one arrives. A condensed Pages Router example is included at the end if your app still uses the pages/ directory.
Create your form endpoint in FormBackend
Go create a login and create a new form endpoint in FormBackend. Give it a name you can remember for example: “NextJS Contact Form” or something similar for this tutorial. It can always be changed later and is only used for you to remember your form. Grab the endpoint URL from the form’s “Setup” tab — you’ll drop it into the code below.
Create a new Next.js app
If you don’t have an existing Next.js app, let’s create one real quick — if you do, then you can skip this section and go to the next one.
Make sure you have Node.js 18.18 or later installed. You can check with node -v and install it from nodejs.org if needed.
npx create-next-app@latest formbackend-nextjs
cd formbackend-nextjs
npm run dev
create-next-app uses the App Router (the app/ directory) by default, so the
rest of this guide assumes that. You can access the local app on
http://localhost:3000. If your project has a pages/ directory instead, you’re on
the Pages Router — skip ahead to the Pages Router example.
Step 1: Handle the form with a Server Action
In the App Router, the simplest way to handle a form is a Server Action — an
async function that runs on the server, with no API route and no client JavaScript.
Create app/contact/actions.js:
"use server" export async function submitContact(formData) { await fetch("https://www.formbackend.com/f/your-form-id", { method: "POST", body: formData, headers: { accept: "application/json" }, }) }
Then create app/contact/page.js and pass the action straight to the form’s action prop:
import { submitContact } from "./actions" export default function ContactPage() { return ( <form action={submitContact}> <label htmlFor="name">Name</label> <input id="name" name="name" type="text" required /> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" required /> <label htmlFor="message">Message</label> <textarea id="message" name="message" required /> <button type="submit">Send message</button> </form> ) }
Replace your-form-id with the endpoint URL from your form’s “Setup” tab. Because the
request happens on the server, your FormBackend URL never reaches the browser — and the
form keeps working even if JavaScript hasn’t loaded yet. That’s the whole thing for a
working form. The next steps make it robust.
Step 2: Validate the submission with Zod
The browser’s required attribute is easy to bypass, so do the real check on the
server. Install Zod and validate the FormData at the top of the
action before forwarding anything to FormBackend:
npm install zod
"use server" import { z } from "zod" const ContactSchema = z.object({ name: z.string().min(1, "Please enter your name"), email: z.string().email("Please enter a valid email"), message: z.string().min(1, "Please enter a message"), }) export async function submitContact(prevState, formData) { const parsed = ContactSchema.safeParse({ name: formData.get("name"), email: formData.get("email"), message: formData.get("message"), }) if (!parsed.success) { return { ok: false, errors: parsed.error.flatten().fieldErrors } } const response = await fetch("https://www.formbackend.com/f/your-form-id", { method: "POST", body: formData, headers: { accept: "application/json" }, }) if (!response.ok) { return { ok: false, errors: { form: ["Something went wrong — please try again."] } } } return { ok: true } }
Notice the action signature is now (prevState, formData) — that’s the shape
useActionState expects in the next step. On a validation failure we return the field
errors as the action’s state instead of throwing, so the UI can render them.
Step 3: Inline errors and a pending state with useActionState
useActionState (from React 19, which Next.js 15 ships with) wraps the Server Action
and hands you back the latest value it returned plus a formAction to pass to the form.
That’s how you surface the Zod errors next to each field and disable the button while the
request is in flight. Make app/contact/page.js a client component:
"use client" import { useActionState } from "react" import { useFormStatus } from "react-dom" import { submitContact } from "./actions" const initialState = { ok: false, errors: {} } function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? "Sending…" : "Send message"} </button> ) } export default function ContactPage() { const [state, formAction] = useActionState(submitContact, initialState) if (state.ok) { return <p role="status">Thanks! Your message has been sent.</p> } return ( <form action={formAction} noValidate> <div> <label htmlFor="name">Name</label> <input id="name" name="name" type="text" aria-describedby={state.errors?.name ? "name-error" : undefined} /> {state.errors?.name && <p id="name-error" role="alert">{state.errors.name[0]}</p>} </div> <div> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" aria-describedby={state.errors?.email ? "email-error" : undefined} /> {state.errors?.email && <p id="email-error" role="alert">{state.errors.email[0]}</p>} </div> <div> <label htmlFor="message">Message</label> <textarea id="message" name="message" aria-describedby={state.errors?.message ? "message-error" : undefined} /> {state.errors?.message && <p id="message-error" role="alert">{state.errors.message[0]}</p>} </div> {state.errors?.form && <p role="alert">{state.errors.form[0]}</p>} <SubmitButton /> </form> ) }
A few things worth calling out:
- Accessibility: every input is tied to its
<label>withhtmlFor/id, each error is linked back to its field witharia-describedby, and errors userole="alert"so screen readers announce them. The success message usesrole="status". - Pending UI:
useFormStatusmust be read from a child of the form, which is why the button lives in its ownSubmitButtoncomponent. - Progressive enhancement: because the action is a real Server Action, the form still submits and validates server-side even before this client JavaScript loads.
A note on next/form
If you searched for “Next.js form” you’ve probably seen Next.js’s own next/form
component. It’s worth knowing what it’s for: next/form extends the HTML <form>
element with client-side navigation and prefetching — it’s designed for forms that
navigate, like a search box that sends you to a results page. It is not the tool
for submitting a contact form to an endpoint. For that, use a Server Action (above) or a
client component with fetch (below) on a normal <form>.
Prefer a client component? Use fetch
If you’d rather not use a Server Action — for example you want to keep everything on the
client — you can POST with fetch instead:
"use client" import { useState } from "react" export default function ContactPage() { const [status, setStatus] = useState("idle") async function handleSubmit(event) { event.preventDefault() setStatus("submitting") const form = event.currentTarget const response = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { accept: "application/json" }, }) if (response.ok) { form.reset() setStatus("success") } else { setStatus("error") } } if (status === "success") { return <p role="status">Thanks! Your message has been sent.</p> } return ( <form method="POST" action="https://www.formbackend.com/f/your-form-id" onSubmit={handleSubmit} > <label htmlFor="name">Name</label> <input id="name" name="name" type="text" required /> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" required /> <label htmlFor="message">Message</label> <textarea id="message" name="message" required /> <button type="submit" disabled={status === "submitting"}> {status === "submitting" ? "Sending…" : "Send message"} </button> {status === "error" && <p role="alert">Something went wrong — please try again.</p>} </form> ) }
Using the browser’s built-in FormData keeps the inputs uncontrolled, so you don’t need
a separate piece of state for each field. The accept: application/json header tells
FormBackend to return JSON instead of a full HTML page, and the response includes a
submission_text you can show as your confirmation message.
Pages Router (legacy)
Using the older pages/ directory? The pattern is a client component with useState
and a fetch call in an onSubmit handler. Create pages/contact.js:
import React, { useState } from "react" export default function Contact() { const [formData, setFormData] = useState({ name: "", email: "", message: "" }) const [formSuccessMessage, setFormSuccessMessage] = useState("") const handleInput = (e) => { setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value })) } const submitForm = (e) => { e.preventDefault() const data = new FormData() Object.entries(formData).forEach(([key, value]) => data.append(key, value)) fetch(e.target.action, { method: "POST", body: data, headers: { accept: "application/json" }, }) .then((response) => response.json()) .then((result) => { setFormData({ name: "", email: "", message: "" }) setFormSuccessMessage(result.submission_text) }) } if (formSuccessMessage) { return <div role="status">{formSuccessMessage}</div> } return ( <form method="POST" action="https://www.formbackend.com/f/your-form-id" onSubmit={submitForm}> <label htmlFor="name">Name</label> <input id="name" type="text" name="name" onChange={handleInput} value={formData.name} /> <label htmlFor="email">Email</label> <input id="email" type="email" name="email" onChange={handleInput} value={formData.email} /> <label htmlFor="message">Message</label> <textarea id="message" name="message" onChange={handleInput} value={formData.message} /> <button type="submit">Send message</button> </form> ) }
The state keys (name, email, message) must match each field’s name attribute.
On submit we build a FormData object, POST it to your FormBackend URL, parse the JSON
response, reset the fields, and show the server’s submission_text. That JSON looks like:
{ submission_text: "Thank you for your submission", redirect_url: null, errors: [], values: { name: "John Doe", email: "hello@formbackend.com", message: "My message" } }
The full example is on GitHub. If you’re starting fresh, prefer the App Router approach above.
Configure notifications and integrations
Your Next.js form is now collecting submissions. Here are some things to set up in FormBackend:
- Email notifications: Get notified every time someone submits, with optional file attachments
- Auto-reply emails: Automatically confirm receipt to the person who submitted
- Spam protection: Built-in spam filtering is active by default. Add Cloudflare Turnstile for extra security
- Integrations: Route submissions to Slack, Google Sheets, Notion, or any URL via webhooks
- Redirect after submission: Send users to a specific page on your site with submitted values in the URL
Want to add forms to a different framework? Check out our guides for React, Vue.js, Astro, Nuxt, and more.
Add a form backend to your site in minutes
Connect any HTML form to FormBackend and start collecting submissions — no backend code required.
Start free