Next.js contact form without an API route

The default Next.js move for a contact form is to scaffold a route handler at app/api/contact/route.ts. Inside it: validate the body, call SendGrid or Resend or Postmark, maybe fire a Slack webhook, return a JSON response. Now you own a piece of server code whose only job is to forward an email. It has env vars, a runtime config, a Vercel rate limit, and an opinion about whether you’re on the edge runtime or Node.

There’s a simpler shape. You don’t need any of it.

The version with no API route

curl -X POST https://api.gopigeon.dev/new -d '[email protected]'

That returns an endpoint URL. Drop it straight into the form’s action attribute:

export default function Contact() {
  return (
    <form action="https://api.gopigeon.dev/f/f_abc123" method="POST">
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button>Send</button>
    </form>
  );
}

That’s the whole contact page. Works in the app router. Works in the pages router. Works under static export (where API routes don’t exist at all). Works on the edge runtime. Works without JavaScript enabled — it’s an HTML form submission.

No app/api/contact/route.ts. No SENDGRID_API_KEY in .env.local. No try/catch around a third-party SDK. The whole “server code that exists only to forward an email” tax is gone.

When you want AJAX (no page reload)

If you want the form to stay on the page after submission — show a spinner, swap to a “thanks!” state — wrap the same endpoint in a client component:

'use client';
import { useState } from 'react';

export default function ContactForm() {
  const [status, setStatus] = useState('idle');

  async function onSubmit(e) {
    e.preventDefault();
    setStatus('sending');
    const res = await fetch('https://api.gopigeon.dev/f/f_abc123', {
      method: 'POST',
      body: new FormData(e.currentTarget),
    });
    setStatus(res.ok ? 'sent' : 'error');
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button disabled={status === 'sending'}>
        {status === 'sent' ? 'Thanks!' : 'Send'}
      </button>
    </form>
  );
}

Still no API route. The endpoint accepts both application/x-www-form-urlencoded (what an HTML form sends) and application/json. The FormData constructor packs the fields without you having to enumerate them.

“But Server Actions”

Next.js 13+ users will reach for server actions instead of an API route. Same critique. Server actions are great when you’re mutating database state with auth checks — that’s what they were built for. A contact form is not that. The destination is an external SaaS, the auth model is “whoever submitted the form,” and the payload is half a dozen string fields. An HTML form with an external action= attribute is simpler, has fewer failure modes, works with JavaScript disabled, and doesn’t tie you to whether your hosting provider supports the Node runtime.

Server actions add value when value can be added. Contact forms aren’t where that line lives.

The Next.js-specific wins

Three things you stop having to think about when the form bypasses your app entirely.

No env vars. No SLACK_WEBHOOK_URL to set in three environments. No “did production read the new value after the deploy?” debugging session. Destination changes happen in the gopigeon dashboard — Slack, Discord, webhook, another email — and take effect on the next submission, no redeploy.

No static-export carveout. If you build with output: 'export' (for Cloudflare Pages, Netlify static, S3 — anywhere serverless is a friction), API routes don’t exist. Your contact form silently breaks at deploy time. An external action= doesn’t care about runtime constraints.

No edge-runtime weirdness. Edge functions don’t have Node APIs, can’t load arbitrary npm packages, and have stricter bundle limits. The SDK you wanted to use for email sending might not run there. None of this matters when the form posts to a URL you don’t control.

What you give up

Honesty time. Skipping the API route means:

  • You can’t run business logic on submission before it leaves the browser (e.g., “look up the submitter’s account and tag the message”). For 99% of contact forms that’s fine; for the 1% where it matters, write the API route.
  • The endpoint URL is visible in client HTML. That’s true of every static-form-handler service. Spam filtering and rate-limiting happen server-side at gopigeon (see the Slack-fan-out post for the spam-and-retries discussion that applies identically here), so a hostile actor copying the URL gets nowhere fast — but it’s worth knowing.
  • You’re now dependent on a third party. So is every Next.js project that touches Resend, SendGrid, Vercel KV, Supabase, or Clerk. Reasonable tradeoff for not maintaining a route handler.

If you’re scaffolding with an AI agent

Claude Code or Cursor with the gopigeon MCP server can do the whole thing in one prompt. The agent calls create_endpoint, drops the returned URL into the form’s action, ships the file. No “please paste your SendGrid API key here” interruption mid-conversation. The contact-form-in-one-turn post walks through the agent flow specifically.

Try it

The curl works with no account:

curl -X POST https://api.gopigeon.dev/new -d '[email protected]'

Next.js framework guide · curl reference · Pricing

← Back to writing