Next.js Forms and Validation
Forms collect names, emails, messages, and orders. Every form needs two safeguards: a clean way to send data and a firm check that the data makes sense. Next.js combines Server Actions with React form tools to deliver both without a separate API layer.
The Everyday View
Think of a bank counter. A customer fills in a slip and hands it to a clerk. The clerk checks the account number, the amount, and the signature before processing anything. A slip with a missing signature goes back with a note that explains the problem.
Your form is the slip. The server is the clerk. Validation is the checking step that protects the system.
Form Flow
Visitor fills form
|
v
Submit button pressed
|
v
Server Action receives FormData
|
+-- data invalid --> return error messages --> form shows them
|
+-- data valid --> save data --> return success message
A Basic Form With a Server Action
A form's action attribute accepts a Server Action function. The browser sends the fields as FormData, and the function runs on the server.
// app/contact/page.js
async function sendMessage(formData) {
'use server';
const name = formData.get('name');
const message = formData.get('message');
await saveMessage({ name, message });
}
export default function ContactPage() {
return (
<form action={sendMessage}>
<input name="name" type="text" />
<textarea name="message" />
<button type="submit">Send</button>
</form>
);
}The form works even before JavaScript loads in the browser, because the browser can submit it as a normal form request.
Why Server Validation Matters
Browser checks such as required and type="email" give quick feedback. Anyone can bypass them with developer tools or a custom request. Server validation acts as the final gate. Keep both layers: browser checks for comfort and server checks for safety.
Writing Validation Logic
A small helper function can check each field and return a list of problems.
function validate(name, email, message) {
const errors = {};
if (!name || name.trim().length < 2) {
errors.name = 'Enter at least 2 characters.';
}
if (!email || !email.includes('@')) {
errors.email = 'Enter a valid email address.';
}
if (!message || message.trim().length < 10) {
errors.message = 'Message needs at least 10 characters.';
}
return errors;
}Showing Errors With useActionState
React provides the useActionState hook for forms. It stores the value returned by the action and exposes a pending flag. The hook needs a Client Component.
// app/contact/actions.js
'use server';
export async function sendMessage(prevState, formData) {
const name = formData.get('name');
const email = formData.get('email');
const message = formData.get('message');
const errors = validate(name, email, message);
if (Object.keys(errors).length > 0) {
return { errors, success: false };
}
await saveMessage({ name, email, message });
return { errors: {}, success: true };
}
// app/contact/ContactForm.js
'use client';
import { useActionState } from 'react';
import { sendMessage } from './actions';
export default function ContactForm() {
const [state, formAction, pending] = useActionState(sendMessage, {
errors: {},
success: false,
});
return (
<form action={formAction}>
<input name="name" />
{state.errors.name && <p>{state.errors.name}</p>}
<input name="email" />
{state.errors.email && <p>{state.errors.email}</p>}
<textarea name="message" />
{state.errors.message && <p>{state.errors.message}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
{state.success && <p>Message sent.</p>}
</form>
);
}Notice the extra first argument in the action. useActionState passes the previous state before the form data.
Schema Libraries
Hand-written checks grow messy as forms grow. Libraries such as Zod describe the expected shape in one place and produce error messages automatically.
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
const result = schema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}The safeParse method never throws. It returns an object that reports success or failure.
Pending State With useFormStatus
A button nested inside a form can read the form status through useFormStatus from react-dom. This pattern keeps a reusable submit button free of extra props.
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
);
}Good Form Habits
- Attach a
labelto every input for screen readers. - Place each error message beside its field.
- Keep typed values in the form after an error appears.
- Trim whitespace before checking length.
- Disable the submit button while the request runs.
- Collect only the information you truly need.
Common Mistakes
Trusting Browser Checks Alone
Attackers skip the browser. Validate on the server every time.
Vague Error Messages
"Invalid input" helps nobody. Say which field failed and what the visitor should type.
Missing the Use Server Directive
A function without 'use server' runs in the wrong place and fails at build time or at runtime.
Quick Summary
Forms in Next.js send data to Server Actions. Validation runs on the server, errors return as state, and useActionState displays them. Add a schema library when the form grows.
