ReactForm
Blog/Next.js

Next.js Form — How to Handle Forms in the App Router (2026)

Next.js App Router gives you two ways to handle forms: client components with useState and fetch, or Server Actions that run entirely on the server with zero API boilerplate. This guide covers both — with validation, loading states, and error handling for each approach.

ReactForm Team··13 min read

Forms in Next.js have changed significantly since the App Router landed. Pages Router apps used client-side state and API routes — a pattern that still works perfectly well. But App Router introduced Server Actions: functions that run on the server and can be called directly from a form's action prop, no API route required. Both approaches have their place, and knowing when to use each saves a lot of time.

1. Two approaches: client vs server

Before writing any code, the key question is: does your form need interactivity while the user is filling it in — real-time validation, conditional fields, dynamic options? Or does it just need to send data to the server on submit?

Most real-world forms benefit from client-side interactivity, so the client component pattern is usually the right default. Server Actions shine for simple, progressive-enhancement scenarios — forms that should work even if the JS bundle fails to load.

2. Client component form with useState

This is the pattern you already know from React — controlled inputs, a submit handler that calls fetch, and state for loading and success. The only Next.js-specific part is the "use client" directive at the top.

app/components/ContactForm.jsx
"use client";
import { useState } from "react";

export default function ContactForm() {
  const [formData, setFormData] = useState({ name: "", email: "", message: "" });
  const [errors, setErrors]     = useState({});
  const [status, setStatus]     = useState("idle"); // idle | loading | success | error

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
    if (errors[name]) setErrors((prev) => ({ ...prev, [name]: "" }));
  };

  const validate = () => {
    const errs = {};
    if (!formData.name.trim())    errs.name    = "Name is required";
    if (!formData.email.trim())   errs.email   = "Email is required";
    else if (!/S+@S+.S+/.test(formData.email)) errs.email = "Enter a valid email";
    if (!formData.message.trim()) errs.message = "Message is required";
    return errs;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const errs = validate();
    if (Object.keys(errs).length) { setErrors(errs); return; }

    setStatus("loading");
    try {
      const res = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(formData),
      });
      if (!res.ok) throw new Error();
      setStatus("success");
    } catch {
      setStatus("error");
    }
  };

  if (status === "success") {
    return (
      <p className="text-teal-600 font-semibold">
        Message sent! We&apos;ll get back to you soon.
      </p>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-5 max-w-md">
      {["name", "email"].map((field) => (
        <div key={field}>
          <label className="block text-sm font-medium mb-1 capitalize">{field}</label>
          <input
            type={field === "email" ? "email" : "text"}
            name={field}
            value={formData[field]}
            onChange={handleChange}
            className="w-full border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-teal-500"
          />
          {errors[field] && <p className="text-red-500 text-xs mt-1">{errors[field]}</p>}
        </div>
      ))}

      <div>
        <label className="block text-sm font-medium mb-1">Message</label>
        <textarea
          name="message"
          value={formData.message}
          onChange={handleChange}
          rows={4}
          className="w-full border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-teal-500 resize-none"
        />
        {errors.message && <p className="text-red-500 text-xs mt-1">{errors.message}</p>}
      </div>

      {status === "error" && (
        <p className="text-sm text-red-500">Something went wrong. Please try again.</p>
      )}

      <button
        type="submit"
        disabled={status === "loading"}
        className="bg-teal-600 text-white font-semibold py-2.5 px-5 rounded-xl hover:bg-teal-700 transition disabled:opacity-60"
      >
        {status === "loading" ? "Sending…" : "Send message"}
      </button>
    </form>
  );
}

The Route Handler that receives this form lives in app/api/contact/route.js. It reads the JSON body and does whatever you need — send an email, write to a database, call a third-party API:

app/api/contact/route.js
export async function POST(req) {
  const { name, email, message } = await req.json();

  // validate server-side too — never trust the client
  if (!name || !email || !message) {
    return Response.json({ error: "Missing fields" }, { status: 400 });
  }

  // send email, write to DB, call Slack webhook, etc.
  // await sendEmail({ name, email, message });

  return Response.json({ ok: true });
}

3. Validation that works for both approaches

A common mistake is validating only on the client. Client-side validation is for user experience — it gives instant feedback. Server-side validation is for correctness — it prevents bad data from reaching your database even if someone bypasses your UI.

Extract your validation logic into a plain function that can run in both places:

lib/validateContact.js
export function validateContact({ name, email, message }) {
  const errors = {};

  if (!name?.trim())    errors.name    = "Name is required";

  if (!email?.trim()) {
    errors.email = "Email is required";
  } else if (!/^[^s@]+@[^s@]+.[^s@]+$/.test(email)) {
    errors.email = "Enter a valid email address";
  }

  if (!message?.trim()) {
    errors.message = "Message is required";
  } else if (message.trim().length < 10) {
    errors.message = "Message must be at least 10 characters";
  }

  return errors; // empty object means valid
}

export const isValid = (errors) => Object.keys(errors).length === 0;

Import validateContact in your client component for real-time feedback and in your Route Handler or Server Action for server-side enforcement. Same logic, one source of truth.

4. Server Actions — the new way

Server Actions let you write a function that runs on the server and wire it directly to a form's action prop — no Route Handler, no fetch, no JSON parsing. Next.js handles the network layer for you.

Mark the function with "use server" at the top of the file (or at the top of the function body if it lives inside a client file):

app/actions/contact.js
"use server";

import { validateContact, isValid } from "@/lib/validateContact";

export async function submitContact(prevState, formData) {
  const data = {
    name:    formData.get("name"),
    email:   formData.get("email"),
    message: formData.get("message"),
  };

  const errors = validateContact(data);
  if (!isValid(errors)) {
    return { success: false, errors };
  }

  // write to database, send email, etc.
  // await db.contacts.create({ data });

  return { success: true };
}

Use it in a Server Component — no "use client" required. The form submits via a standard POST and the action runs on the server:

app/contact/page.jsx
import { submitContact } from "@/app/actions/contact";

export default function ContactPage() {
  return (
    <form action={submitContact} className="flex flex-col gap-5 max-w-md">
      <div>
        <label className="block text-sm font-medium mb-1">Name</label>
        <input name="name" type="text" className="w-full border rounded-xl px-3 py-2.5 text-sm" />
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">Email</label>
        <input name="email" type="email" className="w-full border rounded-xl px-3 py-2.5 text-sm" />
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">Message</label>
        <textarea name="message" rows={4} className="w-full border rounded-xl px-3 py-2.5 text-sm resize-none" />
      </div>

      <button
        type="submit"
        className="bg-teal-600 text-white font-semibold py-2.5 px-5 rounded-xl hover:bg-teal-700 transition"
      >
        Send message
      </button>
    </form>
  );
}

This form works even with JavaScript disabled. When JS is available, Next.js intercepts the submission and calls the action without a full page reload.

5. useActionState for cleaner server forms

The plain Server Action above has a problem — you can't show validation errors back to the user without a full page reload. useActionState (introduced in React 19 and available in Next.js 15) solves this. It gives you the action's return value as React state, so you can show errors inline without leaving the page.

app/contact/page.jsx
"use client";
import { useActionState } from "react";
import { submitContact } from "@/app/actions/contact";

const initialState = { success: false, errors: {} };

export default function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, initialState);

  if (state.success) {
    return <p className="text-teal-600 font-semibold">Message sent! Talk soon.</p>;
  }

  return (
    <form action={formAction} className="flex flex-col gap-5 max-w-md">
      <div>
        <label className="block text-sm font-medium mb-1">Name</label>
        <input name="name" type="text" className="w-full border rounded-xl px-3 py-2.5 text-sm" />
        {state.errors?.name && <p className="text-red-500 text-xs mt-1">{state.errors.name}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">Email</label>
        <input name="email" type="email" className="w-full border rounded-xl px-3 py-2.5 text-sm" />
        {state.errors?.email && <p className="text-red-500 text-xs mt-1">{state.errors.email}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">Message</label>
        <textarea name="message" rows={4} className="w-full border rounded-xl px-3 py-2.5 text-sm resize-none" />
        {state.errors?.message && <p className="text-red-500 text-xs mt-1">{state.errors.message}</p>}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="bg-teal-600 text-white font-semibold py-2.5 px-5 rounded-xl hover:bg-teal-700 transition disabled:opacity-60"
      >
        {isPending ? "Sending…" : "Send message"}
      </button>
    </form>
  );
}

isPending is true while the action is running — use it to disable the submit button and show a loading label. The action's return value lands in state after each submission, giving you errors to display or a success flag to swap the UI.

Next.js + React version note

useActionState requires React 19 and Next.js 15+. On Next.js 14, use the older useFormState from react-dom — same API, different import. On Next.js 13, stick with the client component + Route Handler pattern from section 2.

6. When to use a Route Handler instead

Server Actions are not the right tool for every situation. Reach for a Route Handler (app/api/*/route.js) when:

For forms that are purely internal to your Next.js app, Server Actions reduce boilerplate. For anything that needs to behave like a REST endpoint, a Route Handler is the cleaner choice.

7. File uploads in Next.js forms

File uploads need encType="multipart/form-data" on the form and special handling on the server. With a Route Handler, read the upload via request.formData():

app/api/upload/route.js
export async function POST(req) {
  const formData = await req.formData();
  const file = formData.get("file"); // File object

  if (!file || typeof file === "string") {
    return Response.json({ error: "No file" }, { status: 400 });
  }

  // file.name, file.size, file.type are all available
  const bytes  = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  // write to disk, upload to S3, pass to Cloudinary, etc.
  // await s3.putObject({ Key: file.name, Body: buffer, ContentType: file.type });

  return Response.json({ ok: true, name: file.name, size: file.size });
}

With a Server Action the same formData.get() approach works:

app/actions/upload.js
"use server";

export async function uploadFile(prevState, formData) {
  const file = formData.get("file");

  if (!file || file.size === 0) {
    return { error: "Please select a file" };
  }

  if (file.size > 5 * 1024 * 1024) { // 5 MB limit
    return { error: "File must be smaller than 5 MB" };
  }

  const bytes  = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  // process buffer...

  return { ok: true, filename: file.name };
}

In the form itself, add the encType attribute and an input type="file":

jsx
<form action={uploadFile} encType="multipart/form-data">
  <input type="file" name="file" accept="image/*,.pdf" />
  <button type="submit">Upload</button>
</form>

8. Skip the setup — publish a form in minutes

Building a Next.js form from scratch means wiring up state, validation, a server action or Route Handler, loading states, and error handling — then figuring out where to store the responses. That is a solid afternoon of work for a form that is not the core product.

ReactForm.co lets you build forms visually and publish them with a shareable link in minutes. Responses are stored automatically and viewable in a dashboard — no backend, no database, no deployment. If you need the form as code, you can export a clean React component and drop it into your Next.js app.

Build a Next.js form without the boilerplate

Design visually, publish with a shareable link, collect responses in a dashboard — or export a clean React component for your Next.js app. Free plan, no credit card.

Open the Form Builder →