Add a contact form to Next.js without writing an API route
The instinctive Next.js answer to "how do I handle a contact form" is to reach for a Route Handler: app/api/contact/route.ts, a POST export, maybe a call out to Resend or Nodemailer, maybe a database write if you want the submissions to be more than an email in someone's inbox. That's a legitimate way to build it — and it's also a server, a set of credentials to manage, a database table to create and migrate, and spam handling you now own, all for a feature that should be a five-minute add.
The part that's easy to miss is that you don't need any of it. A contact form is just a POST request. If something else already durably stores it, queues an email, and gives you a dashboard, your Next.js app's job shrinks down to rendering the form and pointing it somewhere. That's true whether you're on the Node runtime, Vercel's edge, or a fully static next export — because there's no API route in the picture at all.
The client component
// app/components/ContactForm.tsx
'use client'
import { useState, FormEvent } from 'react'
const ENDPOINT = 'https://api.submissionbuddy.io/f/your-form'
export default function ContactForm() {
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setStatus('submitting')
const form = event.currentTarget
const data = Object.fromEntries(new FormData(form))
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
setStatus('success')
form.reset()
} catch {
setStatus('error')
}
}
if (status === 'success') {
return <p role="status">Thanks — your message is on its way to us.</p>
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input type="text" name="name" required />
</label>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" required />
</label>
{/* honeypot — hidden from real visitors, catches naive bots */}
<input type="text" name="_honeypot" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send'}
</button>
{status === 'error' && <p role="alert">Something went wrong. Please try again.</p>}
</form>
)
}
Drop <ContactForm /> into any Server Component page — app/contact/page.tsx, a marketing footer, wherever. Because the form itself only needs 'use client' and fetch, there is nothing here that requires a Next.js server at request time: it works identically whether the page is server-rendered, statically generated, or produced by output: 'export' for a fully static deploy to something like S3, Cloudflare Pages, or GitHub Pages. That's the actual point of skipping the API route — you're not just avoiding boilerplate, you're removing a hard requirement for a Node runtime.
No Route Handler needed — really
It's worth saying explicitly: there is no app/api/*/route.ts anywhere in this setup. The browser's fetch call goes directly from the visitor to api.submissionbuddy.io, not through your Next.js server. If you've been avoiding a static export because "the contact form needs an API route," that constraint is gone.
Static HTML fallback
If you'd rather not ship any client JavaScript for the form — say, it's on a marketing page you're keeping as lean as possible — a plain HTML <form> with action/method works too, and needs no 'use client' directive at all:
export default function ContactForm() {
return (
<form action="https://api.submissionbuddy.io/f/your-form" method="POST">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="text" name="_honeypot" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<input type="hidden" name="_redirect" value="https://your-site.com/thanks" />
<button type="submit">Send</button>
</form>
)
}
With _redirect set, a successful submit gets a 303 back and the browser lands on your thanks page — no JavaScript, no client component, works even with JS disabled.
Spam handling
The _honeypot field above is enough to stop the bulk of automated form spam: it's invisible to real visitors, and if it comes back filled, the submission is dropped before it's stored anywhere, though the response still looks like a success so the bot doesn't adapt. It's included free. If your form is a public target for more determined bots, Pro and Business plans layer on an invisible proof-of-work CAPTCHA that runs entirely server-side with no third-party calls.
What happens server-side
A submission is written to durable storage before your fetch call even gets its 202 back. From there it moves through independently retried stages — a worker writes the record into the database your dashboard reads from, and if the form has recipients configured, another sends a notification email. None of that touches your Next.js deployment; it's already handled by the time your response resolves.
Pricing, honestly
Free gets you 100 submissions a month across 2 forms with no card on file — fine for most portfolio sites and small project contact pages. Sign up, grab your endpoint slug, and swap it in for your-form above.