You don't need an API route for a contact form
Almost every "Next.js contact form" tutorial tells you to build a route handler — app/api/contact/route.ts — that receives the form, validates it, and sends an email. If you already use a hosted form backend, that file is dead weight. Look at what it actually does:
// The route you DON'T need: app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const body = await req.formData();
// You re-validate, re-serialize, and forward to a form/email
// service anyway. This entire file is just a proxy with extra steps.
await fetch("https://splitforms.com/api/submit", { method: "POST", body });
return NextResponse.json({ ok: true });
}The browser POSTs to your function, your function POSTs to splitforms, splitforms emails you. That middle hop buys you nothing. Worse, it costs you:
- An extra serverless invocation per submission — with its own cold start and its own place to time out.
- More code to maintain. Validation, error handling, and CORS logic you now own instead of the backend owning it.
- A false sense of security. People add a route to "hide the key," but the splitforms access key is public by design — there is nothing to hide.
- Broken static export. Route handlers need a Node.js runtime, so the moment you add one you can no longer ship a fully static
output: "export"build.
You'd genuinely want a route handler only in three cases: you need to keep a secret credential on the server, you need to transform or enrich the payload before it's stored, or you need to gate the endpoint behind authentication. A public contact form hits none of them. So skip the route and let the form talk to the backend directly. The rest of this guide shows the two ways to do that in the App Router.
Step 1: Get a splitforms access key (1 minute)
Before you touch a component, grab the key that wires your form to a real backend.
- Open splitforms.com/login in a new tab.
- Enter your email and paste the 6-digit code that arrives in your inbox.
- The dashboard auto-generates an access key — copy it. It's a long random string that identifies this one form.
- Optional but recommended: under Security, add your production domain (and your Vercel preview domain, if you test there) to the Allowed Domains list so other sites can't post through your key.
That key is the only credential you need. The free tier gives you 500 submissions/month forever, no card required. Everything below uses YOUR_ACCESS_KEY as a placeholder — swap in the real value.
Step 2 (recommended): a client component that posts directly
This is the option most sites should use. It's a single "use client" component with controlled inputs. On submit it builds a FormData from the form and fetch()es it straight to splitforms — no route, no server function, no SDK.
// app/contact/ContactForm.tsx
"use client";
import { useState } from "react";
type Status = "idle" | "submitting" | "success" | "error";
export default function ContactForm() {
const [form, setForm] = useState({ name: "", email: "", message: "" });
const [status, setStatus] = useState<Status>("idle");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("submitting");
try {
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
// FormData grabs every named field — including the hidden
// access_key and the honeypot — and posts multipart/form-data.
body: new FormData(e.currentTarget),
// Ask for JSON back instead of splitforms' HTML success page.
headers: { Accept: "application/json" },
});
const data = await res.json();
if (data.success) {
setStatus("success");
setForm({ name: "", email: "", message: "" });
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
}
if (status === "success") {
return <p role="status">Thanks — your message is on its way.</p>;
}
return (
<form onSubmit={onSubmit}>
{/* access_key is a public form ID — safe to ship to the browser */}
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<label>
Name
<input
name="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</label>
<label>
Email
<input
type="email"
name="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
/>
</label>
<label>
Message
<textarea
name="message"
rows={5}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
required
/>
</label>
{/* Honeypot: humans never see it; bots fill it and get dropped */}
<input
type="checkbox"
name="botcheck"
tabIndex={-1}
autoComplete="off"
style={{ display: "none" }}
/>
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Sending…" : "Send message"}
</button>
{status === "error" && (
<p role="alert">Something went wrong. Please try again.</p>
)}
</form>
);
}Then drop it into any page — the page itself stays a Server Component:
// app/contact/page.tsx
import ContactForm from "./ContactForm";
export default function ContactPage() {
return (
<main>
<h1>Contact us</h1>
<ContactForm />
</main>
);
}A few details that make this correct rather than merely working:
- Why
new FormData(e.currentTarget)instead of hand-building a JSON body.FormDatareads every field by itsnameattribute, so the hiddenaccess_keyand thebotcheckhoneypot ride along automatically and match the endpoint's expected contract. It's evaluated synchronously whenfetchis called — before theawaitsuspends — soe.currentTargetis still the form at that instant. - Why the
Accept: application/jsonheader. Without it, splitforms responds with its own HTML thank-you page (useful for plain HTML forms with no JS). With it, you get back{ success: true }so you can render your own inline UI and keep the user on the page. - Why controlled inputs. Holding the values in state lets you disable the button while sending, clear the fields on success (
setForm(...)), and layer on live validation later — without reaching into the DOM.
This is the same pattern the framework-specific guide walks through in more depth: contact form for Next.js. The identical approach works in a plain SPA too — see contact form for React.
Step 3 (alternative): a Server Action with zero client JS
If you want the form to work even with JavaScript disabled — real progressive enhancement — use a Server Action instead. The form ships no client bundle; the browser does a native POST, the action runs on the server, forwards the FormData to splitforms, and returns a redirect the browser follows.
// app/contact/actions.ts
"use server";
import { redirect } from "next/navigation";
const ACCESS_KEY = "YOUR_ACCESS_KEY";
export async function submitContact(formData: FormData) {
// Honeypot: bots tick the hidden box, humans never do.
if (formData.get("botcheck")) redirect("/contact?error=spam");
// The key is public, but appending it on the server keeps your
// markup clean and lets you rotate keys without touching the form.
formData.append("access_key", ACCESS_KEY);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: formData,
headers: { Accept: "application/json" },
});
const data = await res.json();
if (!data.success) redirect("/contact?error=send");
// Zero client JS: the server redirects, the browser follows.
redirect("/thanks");
}The page is a Server Component that binds the action with action={submitContact}. There is no "use client", no useState, and no fetch in the browser:
// app/contact/page.tsx — a Server Component
import { submitContact } from "./actions";
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" rows={5} required />
{/* Honeypot */}
<input
type="checkbox"
name="botcheck"
tabIndex={-1}
aria-hidden="true"
style={{ display: "none" }}
/>
<button type="submit">Send message</button>
</form>
);
}Two things to know. First, redirect() works by throwing a special control-flow signal, so never wrap it in a try/catch that swallows errors — call it at the top level of the action as shown. Second, a Server Action is server code: Next.js compiles it into a POST endpoint behind the scenes, which is exactly why it can't run on a static output: "export" build. That's the one real limitation, covered next.
Deciding between the two? The dedicated comparison goes deeper on cold starts, security, and cost: Next.js 15 Server Actions vs a hosted form backend.
Does this work with a static export?
Yes — as long as you use the client component from Step 2. This is the key reason the client approach is the default recommendation.
When you set output: "export" in next.config.js, Next.js emits pure HTML, CSS, and JavaScript with no server at request time. The client component still runs fine: the user's browser makes a cross-origin fetch() to splitforms, and splitforms handles everything server-side. Nothing on your side needs to run. That means the same form works whether you deploy to Vercel, Netlify, Cloudflare Pages, GitHub Pages, or a plain S3 bucket.
What won't survive a static export is anything that needs a live server:
- Server Actions (Step 3) — compiled into a POST endpoint, which a static host has nowhere to run.
- Route handlers (
app/api/*/route.ts) — same reason, which is a second argument for not building one.
So the rule is simple: if your Next.js app is statically exported, use the "use client" fetch component. If you have a running server (the default Vercel/Node deployment), either option is on the table. The broader static-hosting picture — GitHub Pages, JAMstack, and friends — is in how to add a contact form to a static site.
Caveats: the Edge runtime and file uploads
Two things trip people up once forms get more ambitious.
The access_key is public — and that's fine
It bears repeating because it drives every decision above: the access key is a public form ID, not a secret. It only permits posting to one form's endpoint — it can't read submissions or change your account. Shipping it in client HTML is exactly as safe as a Stripe publishable key or a Google Analytics ID. If you want to lock it down, use Allowed Domains rather than hiding the key behind a server.
Edge runtime + multipart file inputs
If you add a Server Action (or route handler) that accepts file uploads and you've opted that segment into the Edge runtime (export const runtime = "edge"), forwarding multipart/form-data can truncate or drop files. Re-serializing a multipart body with its boundary on the edge is fragile, and the Edge runtime enforces tighter request-body size limits than Node.
Two clean fixes:
- Drop the Edge runtime for that route and let it run on the default Node.js runtime, which handles multipart forwarding reliably.
- Send URL-encoded data instead — for a text-only contact form with no
<input type="file">, there's no multipart body to mangle in the first place.
The client component from Step 2 sidesteps this entirely: the browser POSTs the multipart body directly to splitforms, so nothing passes through your Next.js server and the Edge/Node distinction never comes up. If you need attachments, that's the path of least resistance.
Where submissions go, and how spam is stopped
Once a submission reaches splitforms, it lands in two places at once:
- An email notification to the inbox tied to your account — you never configure SMTP or run a mail server.
- Your dashboard, where every submission is stored, searchable, and exportable to CSV. From there you can fan it out to a webhook (Slack, Discord, Notion, a CRM) if you want it somewhere else too.
Spam protection is layered and needs no reCAPTCHA. The hidden botcheck honeypot catches the roughly 80% of spam that comes from dumb bots filling every field — splitforms silently drops any submission where botcheck is non-empty. On top of that, AI classification screens the sophisticated remainder. Because there's no CAPTCHA in front of the form, you don't pay the mobile-conversion tax that Google's widget imposes. The trade-offs are laid out in honeypot vs reCAPTCHA.
Next steps
- The complete framework integration — validation, styling, and redirects — is in the Next.js contact form guide.
- Building a plain SPA instead of App Router pages? See contact form for React.
- On Nuxt or evaluating Vue? The same no-backend pattern is in add a contact form to Nuxt.
- Still weighing whether to use a route at all? Read Server Actions vs form backends.
- If a real submission silently fails, the diagnostic flow is in contact form not working.
- Browse more tutorials on the splitforms blog, or grab your key at splitforms.com/login.
FAQ
Do I need an API route to handle a Next.js contact form?
No. A route handler (app/api/contact/route.ts) that just forwards submissions to a form or email service is pure proxy code — it adds a serverless function, a cold start, and a second network hop for zero benefit. The browser can POST the form directly to splitforms.com/api/submit. Build a route only when you must keep a secret server-side, enrich the payload, or gate the endpoint behind auth. A contact form needs none of those, and the splitforms access key is public, so there's nothing to hide.
Client component or Server Action — which should I use?
Use a "use client" component when you want inline success/error UI, a disabled button while sending, and no full-page navigation — it also works on a statically exported site. Use a Server Action when you want zero client-side JavaScript and progressive enhancement (the form works even with JS disabled); the trade-off is that Server Actions need a server runtime, so they can't run on a fully static output: "export" build. For most marketing sites the client component is the simpler pick.
Where do the submissions actually go?
Every submission lands in two places: your splitforms dashboard (searchable, exportable to CSV, and pushable to webhooks) and an email notification to the inbox tied to your account. You never run an SMTP server or store anything in your own database. Add a webhook if you also want the submission in Slack, Discord, Notion, or your CRM.
Does this work with a static export (output: "export")?
Yes — with the client-component approach. A statically exported Next.js site is just HTML, CSS, and JS on a CDN, and the browser POSTs cross-origin to splitforms with no server involved. What does NOT work on a static export is anything that needs a server at request time: Server Actions and route handlers both require a Node.js runtime, so they're unavailable on output: "export". If you're deploying to GitHub Pages, S3, or Cloudflare Pages as static files, use the "use client" fetch component.
Is it safe to put the access_key in client-side code?
Yes. The access key is a public form identifier, not a secret API token — it only authorizes posting to that one form's endpoint, and it can't read submissions, change settings, or touch your account. It's designed to ship in HTML the same way a Google Analytics measurement ID or a Stripe publishable key is. If you want to restrict who can post with it, add your production domain to the Allowed Domains list in the dashboard so submissions from other origins are rejected.