Next.js Contact Form: 3 Fields, Inline Errors, Server Actions

Published: September 12, 2026 · 5 min read

Build a Next.js landing page contact form with three fields: labeled inputs, per-field errors, a Server Action with useActionState, and a two-column layout.

How many fields should a landing page contact form have?

You have finished a Next.js landing page, and now it is time to add the contact form. Company, phone, department, budget: the list of things you want to ask keeps growing.

A landing page contact form only needs three fields, though: name, email, and message.

One column is enough, or two columns with a short explanation beside the form. Keep motion to a simple entrance when the section scrolls into view, so nothing gets in the way of typing.

This guide covers four things.

  • Why fewer fields means more forms actually get sent

  • A three-field form with real labels and per-field error messages

  • Handling the submission with Server Actions (server functions you call straight from a form)

  • A two-column layout and a pre-launch checklist

Why a contact form with fewer fields gets more replies

People abandon forms for two reasons: it looks like work, or they cannot tell what happens next. You can fix both on your side.

Too many required fields get the tab closed

Seven fields make visitors estimate the effort before they type a single character, and many leave.

You can ask for a phone number or company name later in the conversation. Add a field only when you truly need it.

A button that just says "Submit" hides what happens next

"Submit" does not tell anyone whether a sales call or a PDF is coming. Use a label that describes the result, such as "Book a demo" or "Send my question".

A line like "We reply within 2 business days" next to the button lowers the hesitation further.

One error message at the bottom makes people hunt

"There is a problem with your input" under the button forces people to recheck every field. Show each error directly below the field it belongs to, in words that explain how to fix it.

One-column contact form: the full code

Lock in the look and the copy first, before you wire up delivery. The next section connects the submission.

The client component below (a component that runs in the browser) holds a heading, a one-line explanation, three fields, and a button.

Give every input a real label.

A placeholder alone disappears the moment someone starts typing, and screen readers handle it poorly.

tsx
// app/contact/ContactForm.tsx
  "use client";

  import { useActionState } from "react";
  import { sendContact, type ContactState } from "./actions";

  const initialState: ContactState = { ok: false, errors: {}, values: {} };

  const inputClass =
    "mt-2 w-full rounded-lg border border-zinc-300 bg-white px-4 py-3 text-base outline-none focus:border-zinc-900 aria-[invalid=true]:border-red-500";

  export function ContactForm() {
    const [state, formAction, pending] = useActionState(sendContact, initialState);

    if (state.ok) {
      return (
        <p role="status" className="rounded-xl bg-zinc-100 p-6 text-sm">
          Thanks. We will reply by email within 2 business days.
        </p>
      );
    }

    return (
      <form action={formAction} noValidate className="space-y-5">
        <div>
          <label htmlFor="name" className="text-sm font-medium">Name</label>
          <input
            id="name"
            name="name"
            autoComplete="name"
            defaultValue={state.values.name}
            aria-invalid={Boolean(state.errors.name)}
            aria-describedby={state.errors.name ? "name-error" : undefined}
            className={inputClass}
          />
          {state.errors.name && (
            <p id="name-error" className="mt-1 text-xs text-red-600">{state.errors.name}</p>
          )}
        </div>

        <div>
          <label htmlFor="email" className="text-sm font-medium">Email</label>
          <input
            id="email"
            name="email"
            type="email"
            autoComplete="email"
            defaultValue={state.values.email}
            aria-invalid={Boolean(state.errors.email)}
            aria-describedby={state.errors.email ? "email-error" : undefined}
            className={inputClass}
          />
          {state.errors.email && (
            <p id="email-error" className="mt-1 text-xs text-red-600">{state.errors.email}</p>
          )}
        </div>

        <div>
          <label htmlFor="message" className="text-sm font-medium">Message</label>
          <textarea
            id="message"
            name="message"
            rows={5}
            defaultValue={state.values.message}
            aria-invalid={Boolean(state.errors.message)}
            aria-describedby={state.errors.message ? "message-error" : undefined}
            className={inputClass}
          />
          {state.errors.message && (
            <p id="message-error" className="mt-1 text-xs text-red-600">{state.errors.message}</p>
          )}
        </div>

        <button
          type="submit"
          disabled={pending}
          className="w-full rounded-full bg-zinc-900 px-6 py-3 text-sm font-medium text-white transition hover:bg-zinc-700 disabled:opacity-60"
        >
          {pending ? "Sending…" : "Send my question"}
        </button>
        <p className="text-center text-xs text-zinc-500">We reply by email within 2 business days.</p>
      </form>
    );
  }

Wrapping the form in a contact section

A bare form on a page does not say what it is for. Wrap it with a heading and a one-line explanation, and cap the width around max-w-xl.

Very wide inputs make the eye travel more and feel harder to fill in.

tsx
// app/contact/page.tsx
  import { ContactForm } from "./ContactForm";

  export default function ContactPage() {
    return (
      <section id="contact" className="mx-auto max-w-xl px-6 py-24">
        <h2 className="text-3xl font-semibold tracking-tight">Talk to us</h2>
        <p className="mt-3 text-sm text-zinc-600">
          Three fields. No sales calls.
        </p>
        <div className="mt-10">
          <ContactForm />
        </div>
      </section>
    );
  }

Handle the contact form with Server Actions

In the App Router, you can pass a function marked "use server" to a form's action and handle the input on the server without writing an API route.

The useActionState hook used above (it receives the result of each submit) is a React 19 feature, available in the Next.js 15+ App Router.

The code below validates the three fields and returns per-field errors plus the submitted values when something is wrong. Add your email service or database call where the comment sits.

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

  export type ContactState = {
    ok: boolean;
    errors: { name?: string; email?: string; message?: string };
    values: { name?: string; email?: string; message?: string };
  };

  export async function sendContact(
    _prev: ContactState,
    formData: FormData,
  ): Promise<ContactState> {
    const name = String(formData.get("name") ?? "").trim();
    const email = String(formData.get("email") ?? "").trim();
    const message = String(formData.get("message") ?? "").trim();
    const values = { name, email, message };

    const errors: ContactState["errors"] = {};
    if (!name) errors.name = "Please enter your name.";
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      errors.email = "Please check the email address format.";
    }
    if (message.length < 10) errors.message = "Please write at least 10 characters.";

    if (Object.keys(errors).length > 0) {
      return { ok: false, errors, values };
    }

    // Send the email or save the message here
    return { ok: true, errors: {}, values: {} };
  }

Fixing contact form inputs that clear after an error

In React 19, a form whose action is a function resets its fields after submission. That is why a long message vanishes when validation fails.

Return the submitted values, as the code above does, and pass them to defaultValue so the text stays.

Next.js 14 or an existing form endpoint

Where useActionState is not available, useFormState from react-dom plays a similar role, but it does not return a pending flag, so read it with useFormStatus in the button.

If you already have an API route or a hosted form service, there is no need to switch. A fetch call from the submit handler (onSubmit) is enough.

Two-column contact form that answers doubts first

For B2B demo bookings or quote requests, a two-column layout works well: what happens after sending goes on the left, the form on the right.

Three short lines covering time required, next steps, and privacy remove most of the hesitation.

On mobile, stack the explanation above the form. A Tailwind grid that switches to two columns at lg handles both.

tsx
<section className="mx-auto grid max-w-5xl gap-12 px-6 py-24 lg:grid-cols-2">
    <div>
      <h2 className="text-3xl font-semibold tracking-tight">Book a 30-minute demo</h2>
      <ul className="mt-6 space-y-3 text-sm text-zinc-600">
        <li>30 minutes over screen share, using the real product.</li>
        <li>After you send this, we email you a few time slots.</li>
        <li>We only use your details to get back to you.</li>
      </ul>
    </div>
    <ContactForm />
  </section>

Contact form checklist before launch

A polished form still loses messages if any of these is missing. Test each one once before you ship.

  • Every field has a label, and clicking it focuses the input

  • Errors appear under the field and clear once fixed

  • type="email" brings up the email keyboard on phones

  • A confirmation appears after sending, and double submits are blocked

  • No destination address or secret key lives in client-side code

  • The form states how you will use the data, or links to your privacy policy

If spam picks up, add a hidden honeypot field or rate limiting later. Heavy protection on day one mostly gets in the way of real visitors.

Copy a finished contact section instead

The Video Production Contact page on DesignLayer: check the live preview on the left, then copy the source with Copy for Cursor on the right
The Video Production Contact page on DesignLayer: check the live preview on the left, then copy the source with Copy for Cursor on the right

The code above gives you a working form. Getting the spacing, input heights, button hover, and the overlap with a background photo just right still takes time.

DesignLayer includes finished contact sections, such as a contact block laid over a full-bleed photo for creative studios.

Check the live preview in your browser and copy the code. https://design-layer.com/en/components

Then point the form's target and the field names at your own server function.

A free account copies the 10 Free Sample components with no monthly limit on a free account, plus 1 video prompt a month. Free cannot connect MCP. Full catalog copies are the 30-Day Build Pass ($19 once, no auto-renewal), Lifetime Access ($79 once, first 100 buyers), or Plus ($9/month), and live preview is unlimited. Replace the sample email address and copy before you publish.

Next.js contact form: common questions

  • No. Build the UI first and connect it to an existing API route or a hosted form service if you prefer. For a new build, Server Actions are the easy path because you skip writing a separate API route.

Summary: building a contact form in Next.js

  • Build a landing page contact form with three fields: name, email, message

  • Label every input and show errors under the field

  • Name the button after what happens next, like "Send my question"

  • Receive the input with a server function, and return the values on error

  • For business inquiries, use two columns and explain the next steps up front

The CTA that leads into the form is covered in https://design-layer.com/en/articles/nextjs-cta-section-guide and the footer below it in https://design-layer.com/en/articles/nextjs-footer-section-guide .

To start from a finished section, open the catalog and compare live previews. https://design-layer.com/en/components

Related guides