On this page
Next.js gives you several ways to handle a form, and choosing between them is the confusing part. This guide covers the three main approaches in the App Router, with working code and a clear recommendation for each situation.
Approach 1: Post to a form backend from a client component
Best when you want submissions emailed and stored without writing backend code, or your site is a static export (output: 'export'), where Server Actions and Route Handlers aren't available.
// app/contact/ContactForm.tsx
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setStatus('sending');
try {
const res = await fetch('https://api.formclick.io/submit', {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
const result = await res.json();
if (!res.ok || !result.success) throw new Error(result.message);
form.reset();
setStatus('sent');
} catch {
setStatus('error');
}
}
return (
<form onSubmit={onSubmit}>
<input type="hidden" name="access_key" value={process.env.NEXT_PUBLIC_FORMCLICK_KEY} />
<input type="checkbox" name="botcheck" className="hidden" tabIndex={-1} autoComplete="off" />
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send'}
</button>
<p role="status">
{status === 'sent' && 'Thanks! We will be in touch.'}
{status === 'error' && 'Something went wrong. Please try again.'}
</p>
</form>
);
}
Put the key in .env.local as NEXT_PUBLIC_FORMCLICK_KEY. It's fine for it to reach the browser — a Formclick access key can only submit to its own form, not read anything.
Pros: no server code, works with static exports, spam filtering, email notifications, dashboard and integrations included. Cons: an external service.
Approach 2: Server Actions
Server Actions let a form call a function that runs on the server. They work even before JavaScript loads (progressive enhancement), and pair nicely with useActionState for pending and result states.
// app/contact/actions.ts
'use server';
export type State = { ok: boolean; message: string } | null;
export async function sendMessage(_prev: State, formData: FormData): Promise<State> {
const email = String(formData.get('email') ?? '').trim();
const message = String(formData.get('message') ?? '').trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { ok: false, message: 'Enter a valid email.' };
if (message.length < 10) return { ok: false, message: 'Your message is a little short.' };
// Forward to Formclick from the server (or save to your database, call your CRM…)
const res = await fetch('https://api.formclick.io/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
access_key: process.env.FORMCLICK_KEY,
email,
message,
name: formData.get('name'),
}),
});
const result = await res.json();
return result.success
? { ok: true, message: 'Thanks! Message sent.' }
: { ok: false, message: result.message || 'Please try again.' };
}
// app/contact/page.tsx
'use client';
import { useActionState } from 'react';
import { sendMessage } from './actions';
export default function ContactPage() {
const [state, action, pending] = useActionState(sendMessage, null);
return (
<form action={action}>
<input name="name" aria-label="Name" />
<input name="email" type="email" required aria-label="Email" />
<textarea name="message" required aria-label="Message" />
<button disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
{state && <p role="status">{state.message}</p>}
</form>
);
}
Pros: validation and secrets stay on the server; works without client JavaScript. Cons: needs a server runtime; you still need somewhere to send or store the data.
When a Server Action forwards submissions, every request comes from your server's IP address, so per-visitor protections such as rate limits see one visitor. Add your own spam checks (a honeypot, Turnstile verification) in the action.
Approach 3: Route Handlers (API endpoints)
Use a Route Handler when something other than your own pages submits data — a mobile app, a third-party widget, another site — or when you need a classic JSON API.
// app/api/contact/route.ts
export async function POST(request: Request) {
const data = await request.json();
if (!data.email || !data.message) {
return Response.json({ success: false, message: 'Email and message are required.' }, { status: 400 });
}
// …store, email or forward…
return Response.json({ success: true });
}
Pros: a reusable endpoint for any client. Cons: you build validation, spam protection, CORS and delivery yourself.
Which should you choose?
| Situation | Use |
|---|---|
| Marketing site, contact or lead form | Client component → form backend |
Static export (output: 'export') |
Client component → form backend |
| Logged-in app, data goes to your database | Server Action |
| Need a public API for other clients | Route Handler |
| Want server-side validation and email/dashboard without building them | Server Action → form backend |
Common pitfalls
- Forgetting
'use client'on components that use state or event handlers. - Exposing secret keys — only
NEXT_PUBLIC_variables reach the browser; keep real secrets server-side. - Not handling errors — show the server's message when a submission fails.
- Double submits — disable the button while sending. See How to Prevent Duplicate Form Submissions.
For more React patterns, read How to Integrate HTML Forms With React.



