August 25, 2026 · NBForms Team
Next.js Form Backend: A Contact Form With No API Route
Next.js makes it easy to reach for a Route Handler or Server Action for a contact form. Here's the version that needs neither — plus a fetch-specific gotcha most guides skip.
Ask how to add a contact form to a Next.js app and most answers start the same way: create a Route Handler, or write a Server Action, then have the form call it. Both work. Neither is required for something as small as "collect a name, an email, and a message, then tell me about it" — and building one anyway means a piece of server code that has to be deployed, monitored, and kept working forever, for a job that doesn't need a server at all.
Photo by Mohammad Rahmani on Unsplash
What Next.js normally asks you to build
A Route Handler for this looks roughly like an app/api/contact/route.ts file that reads the
posted body, validates it, sends an email through some provider's SDK, and returns a response —
a small amount of code, but real code, with its own dependency (an email API), its own
environment variable, and its own failure mode to notice when that dependency changes its API
someday. A Server Action collapses the file boundary but not the responsibility: it's still a
function running on your server, on every submission, doing the same three jobs by hand.
Both are legitimate tools. They're the right call when a form needs to do something specific to your app — write to your own database in a custom shape, trigger business logic beyond "someone contacted me." A contact form asking to be received and read isn't that.
The whole thing, without either
'use client'
import { useState } from 'react'
export default function ContactForm() {
const [status, setStatus] = useState('idle')
async function handleSubmit(e) {
e.preventDefault()
setStatus('sending')
const res = await fetch('https://api.nbforms.com', {
method: 'POST',
body: new FormData(e.target),
})
const data = await res.json()
setStatus(res.ok ? 'success' : 'error')
}
if (status === 'success') return <p>Thanks — we'll be in touch!</p>
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="_token" value="YOUR_TOKEN" />
<label>Name</label>
<input type="text" name="name" required />
<label>Email</label>
<input type="email" name="email" required />
<label>Message</label>
<textarea name="message" required />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send'}
</button>
</form>
)
}
e.target inside the handler is the <form> element itself, and new FormData(e.target) reads
every named field on it — so adding a field to the JSX is the entire change needed to collect it;
nothing else in this component has to know it exists. The _token hidden input is what ties the
submission to your NBForms account — generated once from the form's setup page in the dashboard —
and every other field name becomes a column in the submissions dashboard automatically, with no
schema to define first. res.ok tells you whether NBForms accepted the submission, which is
enough to drive the three-state status machine above without any extra state to track.
Validation happens before handleSubmit ever runs
The required attributes on the inputs above aren't decorative. Because this isn't a controlled
form — no value/onChange wired to state on any field, just plain uncontrolled inputs read
through FormData at submit time — the browser's own validation runs first: clicking submit with
the email field empty shows the native "please fill out this field" prompt and handleSubmit
never fires at all. That's a smaller mental model than the validation libraries a lot of React
form tutorials reach for by default (React Hook Form, Formik, Zod resolvers), and it's enough for
a form this size — the fields are exactly the ones a visitor sees, with exactly the constraints
the type and required attributes already express. Server-side, NBForms enforces whichever
fields are marked required in the form's own settings independently, so a request that somehow
skips browser validation (a disabled JS environment, a hand-crafted request) still gets rejected
rather than silently accepted with blanks.
File uploads, and a mistake worth avoiding
Adding <input type="file" name="attachment" /> to the form above works with zero changes to
handleSubmit — new FormData(e.target) picks up File objects from a file input exactly like
it picks up text values, and fetch sends multipart data correctly as long as nothing interferes.
That "nothing interferes" part is the mistake worth naming directly: don't add a Content-Type
header to the fetch call by hand, even out of habit. FormData needs a multipart/form-data
Content-Type with a boundary parameter fetch generates automatically to separate each field in
the request body; setting the header manually — even to the exact string
'multipart/form-data' — overrides that generated boundary with nothing, and the request arrives
unparseable. Leaving the header off entirely, as the snippet above already does, is the correct
move, not an oversight to fix.
The fetch-redirect gotcha most guides skip
NBForms supports a redirect URL — set one in a form's settings and a plain <form action="...">
submission navigates the browser to your own thank-you page afterward. It's tempting to set that
same option here, since it's right there in the form's settings. Don't, for a form submitted with
fetch like this one.
fetch follows redirects transparently by default: if the endpoint responds with a redirect,
the browser follows it and hands back whatever the final URL returns — normally an HTML page,
not JSON. The line right after it, const data = await res.json(), will throw trying to parse
that HTML as JSON, and the catch is easy to miss until a form that worked perfectly in testing
starts failing in production the moment someone fills in the redirect field. Leave that setting
blank for any form submitted with JavaScript; the status state in the component above already
is the thank-you page, no navigation required.
It never touches your Vercel functions
Because the request goes from the browser straight to api.nbforms.com, it doesn't pass through
any function in your own deployment — no Route Handler, no Server Action, no middleware. On a
platform like Vercel, that means a contact form can't add to your serverless invocation count,
can't hit a cold start, and can't be affected by whatever timeout or memory limit your plan sets
on your own functions. The tradeoff other guides don't usually spell out: it also means you have
no server-side hook to intercept the request in transit — anything you'd want to do to a
submission (store it, alert you, filter spam) has to happen on NBForms' side, which is exactly
what it already does, or in your own Route Handler if you decide you need one after all.
Pages Router, same pattern
Nothing above is App Router–specific — there's no Server Component, no 'use server', no
App Router–only API in the snippet. Dropping the same component into a Pages Router project's
pages/contact.js works without modification, since fetch and FormData are browser APIs, not
routing-layer ones. That's a genuine difference from the Route Handler / Server Action approach,
where the two routers ask for meaningfully different code.
What you don't get
Worth saying directly: this collects and stores a structured submission, filters it for spam server-side, and emails an alert — it doesn't run arbitrary business logic. There's no payment processing, no multi-step form with conditional branching, and no automated reply sent back to whoever submitted (only the owner alert, plus whatever success state your component shows, as above). If a form needs to do more than that — trigger a specific downstream workflow unique to your app — that's still a legitimate reason to reach for a Route Handler, calling out to NBForms or storing the submission yourself. For the much larger set of forms that just need to be received and read, none of that machinery has to exist in the first place.
More on what happens after a submission lands — the spam filtering that runs automatically, and how the dashboard and email alerts work — is covered in its own post, along with the full Next.js snippet reference if you want the code without the explanation around it.
Frequently asked questions
Do I need an API route or Server Action for this to work?
No. The form posts straight to NBForms' endpoint with fetch, so there's no Next.js server code involved at all — no app/api/*/route.ts file and no 'use server' action.
Does this work with the Pages Router, or only the App Router?
Both, identically. The pattern is a client-side fetch call, not a Next.js-specific API, so it doesn't matter which router a project uses.
Should I set a redirect URL in my form's settings for a form like this?
No — leave it blank. A redirect URL is meant for a plain HTML form that navigates on submit; a fetch-based submission handles its own success state in JavaScript, and setting one anyway will actually break the response parsing (covered below).
Does the component posting the form need to be a Client Component?
Yes — it needs 'use client' at the top, since it uses useState and an onSubmit handler. That's the only Next.js-specific requirement anywhere in this pattern.
Does submitting count against my Vercel function invocations or execution time?
No. The browser sends the request directly to NBForms' endpoint — it never passes through any function in your Next.js deployment, so it can't be rate-limited, cold-started, or billed against your own serverless usage.