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.
Weighing hosted form services? See our Formspree alternative comparison to see how FormBackend’s pricing and submission limits stack up.
Frequently asked questions
How do I handle a form in the Next.js App Router?
Pass a Server Action — an async function marked "use server" — straight to the form's action prop. The action receives the FormData on the server, so you can validate it and forward it to FormBackend with no API route and no client JavaScript. Adding the useActionState hook on top gives you inline validation errors and a pending state without losing progressive enhancement.
How do I add validation to a Next.js Server Action?
Validate inside the action before you do anything else. Parse the FormData with a schema library like Zod using safeParse, and if it fails return the field errors as the action's state. With useActionState that returned state renders next to each field. Because the check runs on the server it can't be bypassed by disabling client JavaScript.
What is useActionState and why use it for forms?
useActionState (from React 19, used by Next.js 15) wraps a Server Action and gives you back the latest value it returned plus a form action you pass to the form. It is the idiomatic way to surface validation errors, success messages, and pending UI for a Server Action without writing manual useState plumbing.
Is next/form the right component for a contact form?
Not for submitting data. The next/form component extends the HTML form element for client-side navigation and prefetching — it's built for forms that navigate, like search. To send a contact form to FormBackend you use a Server Action (or a client component with fetch) on a normal form element.
Does this work with the Pages Router?
Yes. If your app uses the pages/ directory, use the condensed Pages Router example at the end of this guide. It uses useState and a fetch call inside an onSubmit handler to post submissions to FormBackend. New apps default to the App Router, which is the recommended approach.
How do I add spam protection to a Next.js form?
FormBackend includes built-in spam filtering that is active by default. For stronger protection you can add Cloudflare Turnstile, reCAPTCHA, or hCaptcha — see the spam filtering guides. A honeypot field also works since the form is standard HTML.
How do I get notified when someone submits the form?
FormBackend emails you on every submission and stores them in your dashboard. You can also auto-reply to the sender, and route submissions to Slack, Google Sheets, Notion, or any URL via webhooks.
Keep reading
How to add a form to your Gatsby site
Add a contact form to your Gatsby site with no backend. A complete React example that submits with fetch, shows an inline success message, and reports errors accessibly.
How to create a form in Astro (with Astro Actions)
Add a contact form to your Astro site with no backend. Copy-paste examples for a plain HTML form, a JavaScript submission with an inline thank-you, and the modern Astro Actions approach with server-side Zod validation.
How to create a Svelte form (with validation)
Build a Svelte (SvelteKit) contact form with no backend. Copy-paste examples for a plain form, a JavaScript submission with success and error states, and a SvelteKit form action with server-side Zod validation.
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