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.
In this guide
- 1. Two approaches: client vs server
- 2. Client component form with useState
- 3. Validation that works for both approaches
- 4. Server Actions — the new way
- 5. useActionState for cleaner server forms
- 6. When to use a Route Handler instead
- 7. File uploads in Next.js forms
- 8. Skip the setup — publish a form in minutes
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?
- Client component + fetch — best for forms with live validation, conditional logic, or complex UI state. Familiar React pattern. Requires a Route Handler or external API to receive the data.
- Server Action — best for straightforward forms (sign-up, contact, settings). No API route needed. The action function runs on the server and can write to a database directly. Works without JavaScript enabled.
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.
"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'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:
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:
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):
"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:
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.
"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:
- You need to expose the endpoint to non-Next.js clients (mobile apps, other services)
- You need to set custom response headers (CORS, cache control, redirects)
- You're handling webhooks from third-party services (Stripe, Lemon Squeezy, etc.)
- You need to stream a response
- You want clear separation between your API and your UI code
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():
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:
"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":
<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.
Related articles
React Contact Form — Build, Validate & Collect Submissions (2026)
Step-by-step guide with complete code for a production-ready contact form.
How to Collect Form Responses Without a Backend
Publish a form and collect real responses without writing any server code.
React Form Validation — The Complete Guide Without Libraries
Validate forms properly without Yup, Zod, or React Hook Form.
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 →