Home / Guides

Add a contact form to Next.js without writing an API route

Published 2026-07-15

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.

Stop losing form submissions

SubmissionBuddy stores every submission durably before anything else touches it — 100 free submissions/month, no credit card, live in five minutes.

Start free