How to receive form submissions in Next.js without a backend
You don't need an API route, a Route Handler, or a Server Action to receive a form submission in Next.js. The old advice - "write a pages/api/contact.ts, configure SendGrid, deploy" - is rooted in a time before hosted form backends existed. In 2026, you can ship a Next.js contact form with zero server code.
This post covers three patterns: pure HTML form, client component with fetch, and Server Action with hosted backend. Each has its place; pick by what your form actually needs to do.
Pattern 1: pure HTML form (App Router or Pages Router)
The lightest possible Next.js form. Works in app/, pages/, server components, client components - anywhere. No JavaScript runs on the client for this feature.
// app/contact/page.tsx
export default function ContactPage() {
return (
<form action="https://formspring.io/f/abc123" method="POST">
<input name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
{/* honeypot */}
<div style={{ position: 'absolute', left: '-9999px' }} aria-hidden="true">
<input type="text" name="website" tabIndex={-1} />
</div>
<button>Send</button>
</form>
);
}
When submitted, the browser POSTs to Formspring, Formspring stores the submission, and the visitor is redirected to your configured thank-you page (set in the Formspring dashboard). Zero client JavaScript, zero server code, zero maintenance.
Use when: contact form, no fancy UX, want to keep the bundle clean.
Pattern 2: client component with fetch
If you want an inline thank-you state instead of a redirect, lift the form into a client component and use fetch.
// app/contact/contact-form.tsx
'use client';
import { useState } from 'react';
export function ContactForm() {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('sending');
const res = await fetch(e.currentTarget.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(e.currentTarget),
});
setStatus(res.ok ? 'sent' : 'error');
}
if (status === 'sent') {
return <p className="text-emerald-600">Thanks - we'll reply within a day.</p>;
}
return (
<form
action="https://formspring.io/f/abc123"
method="POST"
onSubmit={onSubmit}
className="space-y-3"
>
<input name="name" required className="block w-full" />
<input type="email" name="email" required className="block w-full" />
<textarea name="message" required className="block w-full" />
<button disabled={status === 'sending'}>
{status === 'sending' ? 'Sending...' : 'Send'}
</button>
{status === 'error' && (
<p className="text-red-600 text-sm">Something went wrong. Try again or email us.</p>
)}
</form>
);
}
// app/contact/page.tsx
import { ContactForm } from './contact-form';
export default function ContactPage() {
return <ContactForm />;
}
The action attribute is still set to the Formspring URL, so if JavaScript fails to load (pattern 1 fallback), the form still submits via standard POST.
Use when: you want polish - inline thank-you, sending state, error handling - without a server endpoint.
Pattern 3: Server Action with Formspring as the storage layer
Server Actions let you keep submission logic inside your Next.js codebase (validation, auth, database writes) while still using Formspring as the storage and notification layer. Best of both worlds when you have specific server-side needs.
// app/contact/actions.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
const ContactSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
message: z.string().min(10).max(5000),
});
export async function sendContact(formData: FormData) {
const parsed = ContactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!parsed.success) {
return { error: 'Please fill out all fields correctly.' };
}
const fd = new FormData();
fd.append('name', parsed.data.name);
fd.append('email', parsed.data.email);
fd.append('message', parsed.data.message);
const res = await fetch('https://formspring.io/f/abc123', {
method: 'POST',
body: fd,
});
if (!res.ok) {
return { error: 'Submission failed. Please try again.' };
}
redirect('/contact/thanks');
}
// app/contact/page.tsx
import { sendContact } from './actions';
export default function ContactPage() {
return (
<form action={sendContact}>
<input name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<button>Send</button>
</form>
);
}
Use when: you need server-side validation, want to gate submissions on user auth, or want to log submissions to your database before forwarding.
The bug that bites everyone: rawBody vs parsed body
If you ever build a webhook receiver in Next.js (to receive Formspring's signed webhook deliveries), there's one bug everyone hits: Next.js parses the request body before your route handler sees it, and HMAC verification needs the raw body.
The fix in App Router:
// app/api/webhook/route.ts
export async function POST(req: Request) {
const rawBody = await req.text(); // raw bytes as string, BEFORE JSON.parse
const signature = req.headers.get('X-Formspring-Signature') ?? '';
const verified = verifyHmac(rawBody, signature, process.env.WEBHOOK_SECRET!);
if (!verified) {
return new Response('Unauthorized', { status: 401 });
}
const payload = JSON.parse(rawBody);
// process...
return Response.json({ ok: true });
}
In Pages Router, you have to disable Next's body parser:
// pages/api/webhook.ts
export const config = {
api: { bodyParser: false },
};
export default async function handler(req, res) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString('utf8');
// verify and process...
}
We covered HMAC verification in How to verify HMAC webhook signatures.
CORS, redirects, and the thank-you page
By default, Formspring redirects to a generic thank-you page after submission. To redirect back to your own thank-you page on the Next.js site:
- In the Formspring dashboard → Form → "Redirect URL", set
https://your-site.com/contact/thanks. - The visitor's browser follows the 303 redirect after the POST completes.
If you're using fetch (pattern 2), set Accept: application/json on the request - Formspring returns JSON instead of redirecting, and your client component handles the success state.
If you're getting CORS errors when fetching, allowlist your origin in Formspring → Form → CORS.
Common questions
Should I use formAction or the Form's action attribute?
Use action on the <form> element. formAction is for buttons that submit to different URLs from the same form - uncommon for contact forms.
Can I use React Hook Form / Formik / TanStack Form with this?
Yes. They handle client-side validation; the submission still POSTs to Formspring. Skip the validation library if your form is short - HTML5 required/type attributes cover most cases without the bundle cost.
What about file uploads in App Router?
enctype="multipart/form-data" works in pure HTML forms (pattern 1). For client components, use FormData (which preserves multipart). For Server Actions, FormData works as long as you don't JSON.stringify it on the way through.
Will this break with Next.js 16 / 17 / future versions?
The <form action="https://..."> pattern is HTML, not Next.js - it'll work as long as HTML forms work. Server Actions and Route Handlers are Next.js APIs and may evolve, but the underlying primitives (forms, fetch, FormData) are stable.
Try Formspring with Next.js
50 submissions/month free, no credit card. Drop the endpoint URL into your form's action attribute and ship. Sign up - most teams have their first form running in under 5 minutes.
Florian Wartner
Founder of Formspring and Pixel & Process. Senior Laravel and Vue engineer based in Lübeck, Germany. Building developer-first SaaS with EU data residency and honest pricing.
Related posts
Astro form handling without serverless functions
How to receive form submissions in an Astro site without writing an API route, server endpoint, or serverless function.
From mailto: to form backend: when to upgrade
The mailto: link works until it doesn't. Five signals that tell you when to upgrade to a real form backend, and what changes when you do.
JAMstack contact form: the complete 2026 guide
Everything you need to ship a contact form on a JAMstack site without spinning up a backend - 5 approaches compared, with real code.