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

The Next.js logo and the Nisuform logo on dark tiles, joined by a line that fades from white to Nisuform pink.

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.

app/contact/ContactForm.jsx
'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>
  )
}
app/contact/page.jsx
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.

ApproachStatic exportWorks without JavaScriptSecret to keepSpam protection
Client component posting to a form backendYesNoNoneBuilt in
Plain HTML form posting to a form backendYesYesNoneBuilt in, without the fill-time check
Server action posting to a form backendNoYesNoneBuilt in, without the fill-time check
Server action with ResendNoYesEmail API keyWhatever 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:

app/contact/actions.js
'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 POST endpoint. 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, or null. 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

  1. 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.

  2. 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 uses useActionState and useEffect, 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.

  3. Render it from a server page

    app/contact/page.jsx imports the form and stays a server component. That is what lets it export metadata for the page title, which a file with 'use client' can't do.

  4. Send with a form action

    useActionState takes an async function and an initial state, and returns the latest state, an action for <form action> and isPending, which is true while the request is in flight. React calls send(previousState, formData) with a ready-made FormData, so you don't call event.preventDefault() or keep state for each input.

    send is a normal async function that runs in the browser. It isn't a server action, because it has no 'use server'.

    The Accept: application/json header asks Nisuform for JSON instead of its thank-you page. Leave out Content-Type when the body is FormData, 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.

  5. 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" }. message is written for people, so the component shows it as is, in a role="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 values with the error and passing them back as defaultValue refills the fields, so a failed send doesn't wipe a long message. Why does my React form clear after submit? explains it in detail.

  6. Test it on localhost

    Run npm run dev, open http://localhost:3000/contact and 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, add http://localhost:3000 to 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:

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'export',
}

export default nextConfig

next 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 basePath and trailingSlash don't affect it. A GitHub Pages project site under /my-site posts 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-site path 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:

app/contact/actions.js
'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 }
  }
}
app/contact/ContactForm.jsx
'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 Origin header. 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 _gotcha honeypot 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:

app/contact/page.jsx
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 _gotcha input is hidden with CSS. People leave it empty, and bots that fill in every input get dropped. Use display: none, not type="hidden", because bots skip hidden inputs.
  • Fill time. _elapsed is 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

SetupWhere it goesNote
App Routerapp/contact/ContactForm.jsx, rendered from app/contact/page.jsx'use client' on the first line of the form's file.
App Router with srcsrc/app/contact/ContactForm.jsxSame files, one folder down.
Pages Routercomponents/ContactForm.jsx, rendered from pages/contact.jsxWorks as is. 'use client' does nothing there and does no harm. Server actions are App Router only.
React 18Any of the aboveuseActionState 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

  1. YOUR_FORM_KEY is replaced with your form's key.
  2. 'use client' is the first line of the form's file, and page.jsx doesn't have it.
  3. Every field has a name and sits inside a <label>.
  4. The request sends Accept: application/json and no Content-Type header with FormData.
  5. The honeypot is hidden with CSS, not type="hidden".
  6. Errors keep what the visitor typed. Test it by going offline in DevTools and pressing send.
  7. You sent a test from the deployed site. If you set allowed websites, the live origin and http://localhost:3000 are 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.

Start collecting form submissions

Set up your forms with built-in spam protection in minutes for free.

Jane Smith

jane@smith.design

Live

12.4k

Submissions

24

Forms

0

Spam