Next.js Contact Form: Client Component vs Server Action
Compare client components and server actions for a Next.js App Router contact form, including an approach that needs no API route and works with static exports.
Nisuform team
13 min read

Short answer: put the form in a client component, the file that starts with 'use client', and post it with fetch to a form backend endpoint, which emails you every submission. It works in both routers, on any host and with output: 'export'. Use a server action only when each message needs server work, like saving it to your own database.
'use client'
import { useActionState, useEffect, useRef } from 'react'
const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'
export function ContactForm() {
const shownAt = useRef(0)
useEffect(() => {
shownAt.current = Date.now()
}, [])
async function send(previousState, formData) {
formData.set('_elapsed', String(Date.now() - shownAt.current))
const values = Object.fromEntries(formData)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
})
const result = await response.json()
if (result.ok) {
return { status: 'sent', message: 'Thanks! We got your message.' }
}
return { status: 'error', message: result.message, values }
} catch {
return { status: 'error', message: 'Something went wrong. Please try again.', values }
}
}
const [state, formAction, isPending] = useActionState(send, { status: 'idle', message: '' })
return (
<form action={formAction}>
<label>
Name
<input name="name" type="text" autoComplete="name" required defaultValue={state.values?.name} />
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
</label>
<label>
Message
<textarea name="message" rows={5} required defaultValue={state.values?.message} />
</label>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: 'none' }} />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send message'}
</button>
<p role="status">{state.message}</p>
</form>
)
}import { ContactForm } from './ContactForm'
export const metadata = {
title: 'Contact',
}
export default function ContactPage() {
return (
<main>
<h1>Contact</h1>
<ContactForm />
</main>
)
}Replace YOUR_FORM_KEY with the key from a free Nisuform form, open /contact, and every message arrives in your inbox and your email.
Four ways to build a Next.js contact form
Next.js runs code in the browser and on a server, so there is more than one place to send a form from. Sending email always needs a secret, like an SMTP password or an email API key, and a secret can only live on a server. What changes is whose server it is.
| Approach | Static export | Works without JavaScript | Secret to keep | Spam protection |
|---|---|---|---|---|
| Client component posting to a form backend | Yes | No | None | Built in |
| Plain HTML form posting to a form backend | Yes | Yes | None | Built in, without the fill-time check |
| Server action posting to a form backend | No | Yes | None | Built in, without the fill-time check |
| Server action with Resend | No | Yes | Email API key | Whatever you build |
1. A client component that posts to a form backend
This is the code above. The browser sends the form straight to the endpoint with fetch, and the endpoint stores the submission, filters spam and emails you. Your Next.js app only serves the page, so the form works on Vercel, Netlify, Cloudflare, GitHub Pages or a folder of static files. The form key is public by design, so there is nothing to hide.
Visitors with JavaScript turned off can't send it.
Nisuform, Formspree and Web3Forms all work this way, so the Next.js code is the same for any of them. If you are still choosing, see the best form backends compared.
2. A plain HTML form
A <form> with the endpoint as its action needs no JavaScript at all, so it can live in a server component. After the submit, the visitor lands on a thank-you page instead of staying on yours. The code is further down.
3. A server action with Resend
This is what most Next.js tutorials and AI coding tools write:
'use server'
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendMessage(previousState, formData) {
const { error } = await resend.emails.send({
from: 'Contact form <form@yourdomain.com>',
to: 'you@yourdomain.com',
replyTo: formData.get('email'),
subject: `New message from ${formData.get('name')}`,
text: formData.get('message'),
})
if (error) {
return { status: 'error', message: 'Something went wrong. Please try again.' }
}
return { status: 'sent', message: 'Thanks! We got your message.' }
}The API key stays on the server, and the form works before JavaScript loads. The rest is yours to build:
- A server action is a public
POSTendpoint. The Next.js docs warn that anyone can call one directly, not only your form, and nothing above stops bots or limits how often it runs. formData.get()returns whatever the request sent, ornull. Every field needs checking before it goes into an email.- To send from your own domain, Resend needs that domain verified with DNS records.
- If the email fails or lands in spam, the message is gone, because nothing stored it.
- It needs a running server, so it can't be part of a static export.
A route handler at app/api/contact/route.js has the same trade-offs, and only works with JavaScript. The React contact form guide shows one.
4. A server action that forwards to a form backend
If you want a server action, for example because you also save each message to your own database, it can post to the form backend instead of Resend. Spam filtering, storage and email are handled again, but Nisuform now sees your server instead of your visitor. The server action version explains what that changes.
Client component or server action?
Pick the client component when:
- You use
output: 'export', or might later. - Your site is mostly static, like a portfolio, a docs site or a landing page.
- You want every spam check, including the fill time and the per-visitor rate limits, to see the real visitor.
Pick a server action when:
- The form must work with JavaScript turned off.
- You need server work on each submit anyway, like writing to your database, reading the signed-in user or calling a private API.
For a contact form on a marketing site, use the client component. It is less code and one network request, it needs no server, and on Vercel no function runs when someone presses send.
Build the client component step by step
Create a form and copy its endpoint
Sign in at app.nisuform.com, choose Create a form, then Endpoint only. The endpoint is on the form's Setup tab and looks like
https://api.nisuform.com/s/YOUR_FORM_KEY. Under Code, pick Next.js to get a component with your endpoint and field names already filled in.Mark the form with 'use client'
In the App Router, every component is a server component unless its file starts with
'use client'. Server components render on the server or at build time, and can't use hooks or event handlers. The contact form usesuseActionStateanduseEffect, so it needs the directive. Put it on the first line, above the imports. Without it, Next.js stops with an error that the hook only works in a Client Component.Only the form needs to be a client component. Keep it in its own file, so the rest of the page stays on the server and ships no JavaScript.
Render it from a server page
app/contact/page.jsximports the form and stays a server component. That is what lets it exportmetadatafor the page title, which a file with'use client'can't do.Send with a form action
useActionStatetakes an async function and an initial state, and returns the latest state, an action for<form action>andisPending, which istruewhile the request is in flight. React callssend(previousState, formData)with a ready-madeFormData, so you don't callevent.preventDefault()or keep state for each input.sendis a normal async function that runs in the browser. It isn't a server action, because it has no'use server'.The
Accept: application/jsonheader asks Nisuform for JSON instead of its thank-you page. Leave outContent-Typewhen the body isFormData, because the browser sets it with the boundary it needs.The fill time starts in
useEffect, which only runs in the browser once the page has hydrated, so it measures how long the visitor had the form open.Show the result and keep what they typed
A stored submission answers
{ "ok": true, "submissionId": "..." }. A rejected one answers with a code and a message, like{ "ok": false, "error": "rate_limited", "message": "Too many requests. Try again shortly" }.messageis written for people, so the component shows it as is, in arole="status"element that screen readers announce. Every code is listed in Errors.React 19 resets uncontrolled fields after every form action, even one that returned an error. Returning
valueswith the error and passing them back asdefaultValuerefills the fields, so a failed send doesn't wipe a long message. Why does my React form clear after submit? explains it in detail.Test it on localhost
Run
npm run dev, openhttp://localhost:3000/contactand send a message. Wait at least 2 seconds before you press send, because faster submissions are dropped as bots, and keep to 5 test submissions a minute. If you set allowed websites on the form, addhttp://localhost:3000to the list.
Next.js contact form with static export
output: 'export' turns your app into plain HTML, CSS and JavaScript files you can host anywhere:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'export',
}
export default nextConfignext build writes the site to out/. Upload that folder to GitHub Pages, Cloudflare Pages, Netlify, an S3 bucket or any web server.
The client component works unchanged, because the browser posts to the endpoint and no part of the form runs on your server. Server actions are on the list of features a static export doesn't support, along with route handlers that read the request, so the Resend versions can't be exported. next dev shows an error if you try.
Two things to check on a static host:
- The endpoint is a full URL, so
basePathandtrailingSlashdon't affect it. A GitHub Pages project site under/my-siteposts to the same endpoint. - If you set allowed websites, add the site's origin, like
https://yourname.github.io. Only the scheme, host and port count, so the/my-sitepath doesn't matter.
The server action version
If your app runs on a server and you want the form to work without JavaScript, or to do something on the server with each message, move send into a server action and keep posting to the form backend:
'use server'
const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'
export async function sendMessage(previousState, formData) {
const values = Object.fromEntries(formData)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
})
const result = await response.json()
if (result.ok) {
return { status: 'sent', message: 'Thanks! We got your message.' }
}
return { status: 'error', message: result.message, values }
} catch {
return { status: 'error', message: 'Something went wrong. Please try again.', values }
}
}'use client'
import { useActionState } from 'react'
import { sendMessage } from './actions'
export function ContactForm() {
const [state, formAction, isPending] = useActionState(sendMessage, { status: 'idle', message: '' })
return (
<form action={formAction}>
<label>
Name
<input name="name" type="text" autoComplete="name" required defaultValue={state.values?.name} />
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
</label>
<label>
Message
<textarea name="message" rows={5} required defaultValue={state.values?.message} />
</label>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: 'none' }} />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send message'}
</button>
<p role="status">{state.message}</p>
</form>
)
}The form still needs 'use client', because useActionState is a hook, but sendMessage runs on your server. Before JavaScript loads, Next.js submits the form as a normal POST and renders the result, so it works for everyone.
What changes is who Nisuform sees:
- Your server's IP address, not the visitor's. Rate limits count per IP address, and one IP address can send 5 submissions a minute to one form, or 50 on Pro and Business. Submissions that leave from the same server share that limit, so a burst of real messages can be rejected with
rate_limited. - Your server's location. The inbox shows your server's IP address and location on every submission, and blocking that IP address would block your own form.
- No
Originheader. Allowed websites only apply to browser requests, so they don't guard the action, and anyone can call a server action directly. - No fill time. The browser doesn't add
_elapsed, so the fill-time check is skipped. The_gotchahoneypot still works, because it is part of the form data you forward.
Use this version when the server work is worth it. For a plain contact form, the client component gives the spam checks more to work with.
A Next.js contact form with zero JavaScript
A form with a URL as its action is plain HTML. React leaves it alone, so it works in a server component and ships no JavaScript:
export const metadata = {
title: 'Contact',
}
export default function ContactPage() {
return (
<main>
<h1>Contact</h1>
<form action="https://api.nisuform.com/s/YOUR_FORM_KEY" method="POST">
<label>
Name
<input name="name" type="text" autoComplete="name" required />
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required />
</label>
<label>
Message
<textarea name="message" rows={5} required />
</label>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: 'none' }} />
<button type="submit">Send message</button>
</form>
</main>
)
}On submit, the browser leaves your site and shows Nisuform's thank-you page, with a link back to the page the visitor came from. On Pro, set a redirect in the form's After submit settings, or add a _redirect field, to send visitors to your own page instead:
<input type="hidden" name="_redirect" value="https://example.com/contact/thanks" />This version works with static export too. It sends no fill time, so the honeypot and the checks on Nisuform's side do the spam filtering.
Stop spam without a CAPTCHA
The client component already sends two invisible checks:
- Honeypot. The
_gotchainput is hidden with CSS. People leave it empty, and bots that fill in every input get dropped. Usedisplay: none, nottype="hidden", because bots skip hidden inputs. - Fill time.
_elapsedis the number of milliseconds between the form appearing and the submit. Anything under 2 seconds is dropped.
Bots caught by either check still get { "ok": true }, so they can't tell what gave them away. Nisuform also rate limits each visitor, sends submissions stuffed with links to a spam folder, and can reject posts from any site that isn't on your form's allowed websites.
If a form still gets hit, add Cloudflare Turnstile. Its token is a field inside the form, so FormData sends it and send doesn't change. Turnstile's automatic rendering scans the page when its script loads, and Cloudflare recommends explicit rendering for single-page apps. On a page visitors reach through a Next.js <Link>, render the widget with turnstile.render() in an effect, or use a wrapper like @marsidev/react-turnstile.
How to add a contact form to a static website without a backend goes deeper on every check and when each one is worth it.
App Router, Pages Router and TypeScript
| Setup | Where it goes | Note |
|---|---|---|
| App Router | app/contact/ContactForm.jsx, rendered from app/contact/page.jsx | 'use client' on the first line of the form's file. |
App Router with src | src/app/contact/ContactForm.jsx | Same files, one folder down. |
| Pages Router | components/ContactForm.jsx, rendered from pages/contact.jsx | Works as is. 'use client' does nothing there and does no harm. Server actions are App Router only. |
| React 18 | Any of the above | useActionState needs React 19. Use the onSubmit version instead. |
In TypeScript, rename the file to ContactForm.tsx and type the state and the action:
type FormState = {
status: 'idle' | 'sent' | 'error'
message: string
values?: Record<string, string>
}
const initialState: FormState = { status: 'idle', message: '' }
async function send(previousState: FormState, formData: FormData): Promise<FormState> {The full file is in the React contact form guide. Only the 'use client' line differs.
You don't need an environment variable, because the form key is safe to publish. A NEXT_PUBLIC_ variable wouldn't hide it anyway, since Next.js copies its value into the JavaScript the browser downloads. If you want a separate test form in development, you can still read the endpoint from process.env.NEXT_PUBLIC_FORM_ENDPOINT.
Send submissions to Slack, Discord or Google Sheets
Email is only the default. On Pro, the same Next.js form can post each submission to Slack, Discord, Telegram or Google Sheets, or to your own code with a webhook, and send the visitor an auto-reply. None of it touches your Next.js code.
Checklist before you ship
YOUR_FORM_KEYis replaced with your form's key.'use client'is the first line of the form's file, andpage.jsxdoesn't have it.- Every field has a
nameand sits inside a<label>. - The request sends
Accept: application/jsonand noContent-Typeheader withFormData. - The honeypot is hidden with CSS, not
type="hidden". - Errors keep what the visitor typed. Test it by going offline in DevTools and pressing send.
- You sent a test from the deployed site. If you set allowed websites, the live origin and
http://localhost:3000are both on the list.
For other frameworks, see the React contact form guide and the Vue, Svelte and Astro docs.
Frequently asked questions
Can a Next.js contact form send email without an API route?
Yes. Put the form in a client component and post it with fetch to a form backend endpoint. The service stores each submission, filters spam and emails you, so you don't need an API route, a server action or an email API key. Because the browser posts straight to the endpoint, it also works with output: 'export' and on static hosts. Nisuform's Free plan covers 100 submissions a month and needs no credit card.
Should I use a server action for a Next.js contact form?
Only if you need what a server action adds. A server action works before JavaScript loads and can do server work on each message, like saving it to your database. It also needs a running server, can't be part of a static export, and runs a function on every submit. For a plain contact form, a client component that posts to a form backend is less code and works on any host.
Does a Next.js contact form work with output: 'export'?
Yes, if the form posts from the browser. A client component that sends the form to a form backend with fetch works unchanged in a static export, and so does a plain HTML form whose action is the endpoint URL. Server actions and route handlers that read the request are not supported with output: 'export', so forms built on them can't be exported.
Why does my Next.js form throw an error about client components?
In the App Router, every component is a server component unless its file starts with 'use client'. Server components can't use hooks like useActionState, useState or useEffect, or event handlers like onSubmit. Add 'use client' as the first line of the form's file, above the imports, and keep the page that renders it as a server component so it can still export metadata.
Is it safe to put the form endpoint in a Next.js client component?
Yes. A Nisuform endpoint only accepts new submissions. Nobody can read your submissions or change your form with it, so it is fine in browser code. An email API key, like a Resend key, is different and must stay on the server. A NEXT_PUBLIC_ environment variable doesn't hide anything, because Next.js puts its value into the JavaScript the browser downloads. To stop other sites from posting to your endpoint, add your domains to the form's allowed websites.
How do I send a Next.js contact form with Resend?
Write a server action or a route handler that reads the form data and calls resend.emails.send with your RESEND_API_KEY, which stays on the server. You also need a verified sending domain, field validation, spam checks and rate limits, because a server action is a public POST endpoint. It only works where Next.js runs on a server, not in a static export. A form backend handles those parts for you.
How do I test a Next.js contact form on localhost?
Run npm run dev and submit the form at http://localhost:3000. It works from localhost unless you set allowed websites, in which case add http://localhost:3000 to the list. Wait at least 2 seconds before you submit, because faster submissions are treated as bots, and keep to 5 test submissions a minute per form, the rate limit for one IP address.

