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
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.

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