Tutorial

Tutorial: Build a contact form in Next.js App Router

March 15, 2026 · Kabim Tech Engineering · 2 min read

← Back to blog
Developer building a Next.js application on a laptop
Developer building a Next.js application on a laptop
Incremental adoption of the Next.js App Router (official guide)
  • Nextjs
  • React

Step-by-step guide to a validated contact form with Server Actions, client-side UX, and accessible field errors in Next.js 16.

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

  1. 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 }`.
  2. Add `components/contact-form.tsx` as a client component with `useActionState` (or `useFormState`) wired to the server action.
  3. Build controlled inputs for name, email, and message. Set `aria-invalid` and `aria-describedby` when a field fails validation.
  4. Add a submit button that disables while `pending` is true from the action state hook.
  5. Render a success panel when the action returns `ok: true`, with a button to reset the form.
  6. 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.

Want to talk about your product?

Need a dedicated squad or a fixed-scope MVP? We help teams ship on time.