A Next.js contact form backed by a real endpoint

React gives you two honest places to send a contact form from: the browser, or a Route Handler on the server. Both end at the same SubmitHarbor URL, and this guide shows each path with the failure branches written out — the parts tutorials usually skip.

First decision: who performs the POST?

The choice affects your origin settings, your bundle, and how much of the endpoint a visitor can see. Neither option is wrong; they trade differently.

Directly from a Client Component
The visitor's browser posts cross-origin, so the request carries an Origin header and your project's allow-list governs it. Zero extra latency, zero server cost, and the endpoint URL is visible in view-source — which is fine, because the key is public by design.
Through your own Route Handler
Your server forwards the payload. No Origin header is attached to a server-to-server call, so allow-list rules never fire on it, previews on throwaway domains keep working, and you gain one place to log or reshape traffic — at the price of an extra hop on every message.

Option one: post from the component

A single Client Component owns the form. Note the branches: a 201 or a filtered 200 both deserve a thank-you, a 422 carries a sentence meant for the visitor's eyes, and a 429 asks for patience rather than a retry storm.

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

import { useState } from "react";

const ENDPOINT =
  "https://www.submitharbor.com/api/submit/sh_your_project_key";

export function ContactForm() {
  const [note, setNote] = useState("");

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = Object.fromEntries(new FormData(event.currentTarget));
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    const result = await response.json();

    if (response.status === 429) {
      setNote("Hold on a moment, then send again.");
    } else if (!response.ok) {
      setNote(result.error ?? "That did not go through.");
    } else {
      setNote("Sent — thank you.");
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <label>
        Email
        <input type="email" name="email" required />
      </label>
      <label>
        Message
        <textarea name="message" required minLength={3} />
      </label>
      {/* Humans never render this; bots happily fill it. */}
      <input type="text" name="website" tabIndex={-1} autoComplete="off" hidden />
      <button type="submit">Send</button>
      <p role="status">{note}</p>
    </form>
  );
}

Everything in the submitted object travels: the honeypot arrives empty for humans, and populated values are consumed server-side with a quiet filtered: true. You never write filtering logic twice.

Option two: a Route Handler in front

App Router handlers run only on the server, so the endpoint URL can live in an environment variable instead of the client bundle.

app/api/contact/route.ts
export async function POST(request: Request) {
  const upstream = await fetch(process.env.SUBMITHARBOR_ENDPOINT!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(await request.json()),
    cache: "no-store",
  });

  return Response.json(await upstream.json(), { status: upstream.status });
}
.env.local
SUBMITHARBOR_ENDPOINT=https://www.submitharbor.com/api/submit/sh_your_project_key

Because the handler calls from your infrastructure, no Origin header rides along and the allow-list stays out of its way. Pass the upstream status through untouched — the 422 error sentence flows to your interface as-is, and a 503 surfaces honestly instead of masquerading as success.

Registering the places you deploy from

Direct browser posts are checked against the project's origin list, so tell it about every URL that hosts the form.

Project settings, one origin per line
http://localhost:3000
https://your-app.vercel.app
https://your-domain.com

Preview deployments mint a fresh subdomain each time, which is the strongest argument for routing previews through the Route Handler while keeping the allow-list tight for production. Drop the localhost entry once development quiets down.

The four replies worth coding against

ResponseMeaningWhen you get it
201 CreatedAcceptedThe submission passed validation. The JSON body reports whether it was stored and includes an X-RateLimit-Remaining header.
200 OKFilteredA honeypot field was populated. The response reports filtered: true and stored: false so a bot cannot tell it was caught.
422 Unprocessable ContentValidation failedA field limit was exceeded, a field name was unusable, or email and message did not pass their checks. The error string is safe to show a visitor.
429 Too Many RequestsRate limitedMore than 20 submissions per minute from one address to one endpoint. A Retry-After header is included.

Storage outages answer 503; show a retry-later message.

Prove the endpoint before touching React

Run this now — the shared demo key is open and answers immediately
curl -i -X POST https://www.submitharbor.com/api/submit/demo_contact_7x2p \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","message":"Testing the demo endpoint."}'

The shared demo key validates a submission and echoes it back, then discards it. It never writes to the database, because a stored demo submission would leave a stranger's details in an inbox nobody owns. Create your own endpoint to keep what arrives.

What SubmitHarbor does not do

Worth reading before you build on it. These limits apply today.

  • The shared signed-out demo validates and echoes a preview; durable endpoints, storage, and notifications require a configured signed-in project.
  • SubmitHarbor does not promise an uptime SLA, attachments, CAPTCHA providers, webhooks, CRM integrations, or unlimited submissions.
  • Origin checks and honeypots reduce common abuse but do not replace a complete security and privacy review for sensitive forms.

Questions about Next.js contact form backend

Should the endpoint URL sit in client-side JavaScript?

It can. A project key is a public identifier that permits submissions and nothing else — the inbox requires your signed-in session. Hide it behind a Route Handler only if you prefer the tidier surface.

Why did my perfectly typed message fail with 422 in development?

Usually the trimmed message fell under three characters or the email lacked a dotted domain. The returned error string is written to be shown to the visitor verbatim, so render it.

Do Server Actions work instead of a Route Handler?

Yes — a Server Action executes server-side too, so its fetch behaves identically: no Origin header, allow-list bypassed, environment variables available.

Will rapid hot reloads exhaust the quota while developing?

Possibly. The window allows twenty submissions a minute per address, and a fast loop of test submits can hit it. The 429 response includes Retry-After, so pause briefly and continue.