Back to blog
guides

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.

J
Jesper Christiansen
10 min read
Header graphic that shows the Next.js logo

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> with htmlFor/id, each error is linked back to its field with aria-describedby, and errors use role="alert" so screen readers announce them. The success message uses role="status".
  • Pending UI: useFormStatus must be read from a child of the form, which is why the button lives in its own SubmitButton component.
  • 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:

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.

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