In this tutorial you will build a production-style contact form using the Next.js App Router, client validation, server action stub, and accessible error messages. No external backend required for the first version.
What you will build
- A two-column form layout (name, email, message)
- Client validation with instant field errors
- A Server Action that sanitizes input and returns success state
- Loading and success UI after submit
Prerequisites
- Node.js 20+ installed
- Basic React and TypeScript knowledge
- A Next.js app created with `create-next-app` (App Router enabled)
Step-by-step
- Create `app/contact/actions.ts` and export an async `submitContact` Server Action that accepts FormData, validates required fields, and returns `{ ok: true }` or `{ error: string }`.
- Add `components/contact-form.tsx` as a client component with `useActionState` (or `useFormState`) wired to the server action.
- Build controlled inputs for name, email, and message. Set `aria-invalid` and `aria-describedby` when a field fails validation.
- Add a submit button that disables while `pending` is true from the action state hook.
- Render a success panel when the action returns `ok: true`, with a button to reset the form.
- Style fields with consistent focus rings and error borders using CSS variables or Tailwind focus utilities.
Server Action example
"use server";
export async function submitContact(
_prev: unknown,
formData: FormData,
) {
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const message = String(formData.get("message") ?? "").trim();
if (!name || !email || message.length < 10) {
return { error: "Please complete all fields (message min 10 chars)." };
}
// TODO: send email or save to CRM
return { ok: true as const };
}Client form wiring
"use client";
import { useActionState } from "react";
import { submitContact } from "@/app/contact/actions";
export function ContactForm() {
const [state, action, pending] = useActionState(submitContact, null);
if (state?.ok) {
return <p role="status">Thanks, we will reply within one business day.</p>;
}
return (
<form action={action} noValidate>
{/* inputs + {state?.error && <p role="alert">...</p>} */}
<button type="submit" disabled={pending}>
{pending ? "Sending…" : "Send message"}
</button>
</form>
);
}Test your work
- Submit empty fields, errors should appear without a full page reload
- Submit valid data, success message renders
- Tab through fields, focus order is logical and visible
Next steps
Connect the action to Resend, SendGrid, or your CRM API. Add rate limiting on the route and honeypot spam protection before going live.


