Guides

How to build a Next.js contact form

A contact form for the App Router with a Server Action, pending state, field errors, and email notifications. No API route and no email provider to set up.

Updated · 8 min read

Next.js gives you everything to receive a form: Server Actions, route handlers, and React's useActionState. What it doesn't give you is somewhere for the messages to go. Most tutorials fill that gap with an API route, an email API key, and a verified sending domain, before writing a line of spam protection.

This guide builds a contact form for the App Router with a Server Action, a pending state, and per-field errors. Submissions go to Heapform, which stores them, filters spam, and emails you, so there is no API route or email provider to set up.

What you'd otherwise build

For comparison, here is the outline of a do-it-yourself route handler:

app/api/contact/route.ts
// app/api/contact/route.ts: what you'd write without a form backend
export async function POST(request: Request) {
  const data = await request.formData();
  // 1. Validate every field (and return errors in a shape your UI understands)
  // 2. Check for bots: honeypot, rate limit per IP, maybe a CAPTCHA
  // 3. Save the message somewhere you can search later (a database table)
  // 4. Send yourself an email through Resend, Postmark, SES...
  //    (an account, an API key, a verified sending domain)
  return Response.json({ success: true });
}

None of it is hard, but it is code to test, secrets to manage, and an email domain to keep out of spam folders. If your contact form is not the product, you can skip all four steps.

Step 1: Create the form in Heapform

  1. Create a free account and add a form in the dashboard.
  2. Add your email address as a notification recipient in the form's settings.
  3. Copy the form's slug. It is the last part of the endpoint URL, https://forms.heapform.com/f/YOUR_FORM_SLUG.

Step 2: Install the React SDK

Terminal
npm install @heapform/react

@heapform/react is a small headless package: it handles submitting and reading errors, and leaves the markup and styling to you. The Next.js helpers live under @heapform/react/next.

Step 3: Add a Server Action

Create an actions file next to your contact page. submitHeapformAction posts the form data to Heapform and returns a serializable state for useActionState.

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

import { submitHeapformAction, type HeapformActionState } from "@heapform/react/next";

export async function contactAction(_previousState: HeapformActionState, formData: FormData) {
  return submitHeapformAction("YOUR_FORM_SLUG", formData);
}

Step 4: Build the form component

The form is a client component so it can show pending and success states. useActionState wires the action to the form, and HeapformValidationError renders Heapform's error message for a field, or form-level errors when you leave out field.

app/contact/contact-form.tsx
"use client";

import { HeapformValidationError } from "@heapform/react";
import { getHeapformActionErrors, initialHeapformActionState } from "@heapform/react/next";
import { useActionState } from "react";

import { contactAction } from "./actions";

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(contactAction, initialHeapformActionState);
  const errors = getHeapformActionErrors(state);

  if (state.succeeded) {
    return <p role="status">Thanks! Your message has been sent.</p>;
  }

  return (
    <form action={formAction} className="grid max-w-lg gap-3">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" autoComplete="name" required />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <HeapformValidationError errors={errors} field="email" prefix="Email" />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required />
      <HeapformValidationError errors={errors} field="message" />

      {/* Honeypot: hidden from people, filled in by bots */}
      <input
        type="text"
        name="_gotcha"
        tabIndex={-1}
        autoComplete="off"
        style={{ display: "none" }}
      />

      <HeapformValidationError errors={errors} />

      <button disabled={isPending} type="submit">
        {isPending ? "Sending..." : "Send message"}
      </button>
    </form>
  );
}

A few things this gets right without extra code:

  • Progressive enhancement. Because the form uses a Server Action as its action, it still submits before JavaScript has loaded.
  • Server-side validation. Rules you add under Validation in the dashboard (required, URL, phone number, min and max length...) come back as field errors and appear next to the right input.
  • Spam filtering. The hidden _gotcha field catches simple bots, and Heapform's filters handle the rest. Flagged messages are kept in the dashboard but don't email you.

Step 5: Render it on a page

app/contact/page.tsx
import type { Metadata } from "next";

import { ContactForm } from "./contact-form";

export const metadata: Metadata = {
  title: "Contact us",
};

export default function ContactPage() {
  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm />
    </main>
  );
}

Run npm run dev, open /contact, and send a test message. It shows up under Submissions in the dashboard, and the notification email arrives a few seconds later.

Alternative: a client-only form

Server Actions need the App Router and a server. For the Pages Router, a static export (output: "export"), or any other React app, use the useHeapform hook instead. It posts from the browser straight to Heapform:

contact-form.tsx
"use client";

import { HeapformValidationError, useHeapform } from "@heapform/react";

export function ContactForm() {
  const [state, handleSubmit, reset] = useHeapform("YOUR_FORM_SLUG");

  if (state.succeeded) {
    return (
      <div role="status">
        <p>Thanks! Your message has been sent.</p>
        <button type="button" onClick={reset}>
          Send another
        </button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <HeapformValidationError errors={state.errors} field="email" prefix="Email" />

      <textarea name="message" required />
      <HeapformValidationError errors={state.errors} />

      <button disabled={state.submitting} type="submit">
        {state.submitting ? "Sending..." : "Send"}
      </button>
    </form>
  );
}

Next steps

  • Need a different form? The HTML form templates convert to JSX by renaming class to className.
  • Building without React? Follow the HTML contact form guide.
  • Want to forward submissions to Slack, a CRM, or your own database? Add a webhook in the form settings (Pro plan and up).

Frequently asked questions

Make your form work in minutes

Create a form endpoint, paste it into your form, and get an email for every submission.

Start for free