The short answer
On Vercel you can handle a contact form with an API route or Server Action, but that turns a static deployment into one with server code you own, plus a database or email API to store and forward submissions. If the form only needs to reach your inbox and leave a record, an external endpoint keeps the deployment purely static: the browser POSTs directly to a Google Apps Script web app in your own Google account, submissions land in your own Google Sheet, no function is invoked, and there is nothing extra to maintain or pay for.
What the API route actually commits you to
The handler itself is trivial. Everything around it isn’t. Storage: a serverless function has no memory between invocations, so you need Postgres, KV, or a third-party store — provisioned, connected, and paid for. Email: you’ll be calling an email API, which means an account, an API key in your environment, a verified sending domain, and a deliverability problem when messages start landing in spam. Abuse: a public POST route with no rate limiting will eventually get found.
Every piece is reasonable on its own. Together they’re a small application, and it exists to service a contact form.
Keeping it static instead
Point the form at an endpoint that already does all of it. Your Vercel project stays a pure static deployment: no functions in the bundle, no invocations metered, no cold starts on your side, no environment secrets to manage.
This works identically whether you’re on Next.js with output: 'export', Astro, Vite, SvelteKit static, or plain HTML.
The component
For a Next.js App Router project, mark it 'use client' — it needs state and an event handler.
'use client';
import { useState } from 'react';
const ENDPOINT = process.env.NEXT_PUBLIC_FORM_ENDPOINT!;
export default function ContactForm() {
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setState('sending');
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }, // avoids the CORS preflight
body: JSON.stringify({
tab: 'contact',
fields: Object.fromEntries(new FormData(form)),
}),
});
const data = await res.json();
setState(data.result === 'success' ? 'sent' : 'error');
if (data.result === 'success') form.reset();
} catch {
setState('error');
}
}
if (state === 'sent') {
return <p role="status">Thanks — we'll be in touch shortly.</p>;
}
return (
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<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="text"
name="_hp"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute -left-[9999px]"
/>
<button type="submit" disabled={state === 'sending'}>
{state === 'sending' ? 'Sending…' : 'Send message'}
</button>
{state === 'error' && (
<p role="alert">
Something went wrong. Email us directly at hello@example.com.
</p>
)}
</form>
);
}The NEXT_PUBLIC_ prefix is required for the value to reach the browser. That’s fine here — the endpoint URL is not a secret, and can’t be: it has to be publicly callable for visitors to submit.
Vercel-specific notes
Set `NEXT_PUBLIC_FORM_ENDPOINT` in Project Settings → Environment Variables — across Production, Preview and Development, so preview deployments work too.
Preview deployments work unchanged — the endpoint accepts requests from any origin, so every preview URL submits successfully.
No `runtime` or `dynamic` exports needed — the page stays fully static; only the browser talks to the endpoint.
Server Actions aren’t needed either — and using one would move you back to a deployment with server code in it.
Sanity-check the endpoint
curl -L -X POST "https://script.google.com/macros/s/AKfycb.../exec" \
-H "Content-Type: text/plain" \
-d '{"tab":"contact","fields":{"name":"Ada","email":"ada@example.com","message":"Hello"}}'
# → {"result":"success"} and a new row appears in your Google SheetCommon questions
Does this work with Next.js `output: "export"`?
Yes — that’s the ideal case. Fully static export, no functions, and the form still works because the submission happens in the browser.
Will this count against my Vercel function invocations?
No. No function is invoked. The request goes from the visitor’s browser straight to the Apps Script endpoint and never touches your Vercel deployment.
Can I use a Server Action instead?
You can, but it puts server code back into the deployment and you still need storage and an email provider behind it. If you’re going that far, a proper API route is the cleaner shape.
Is the endpoint URL safe in a public bundle?
Yes. It’s a public write endpoint by design, protected by the honeypot and optional Turnstile rather than by secrecy. It grants no access to your Google account — the script can only touch its own spreadsheet.
More answers in the full FAQ.