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

Published 2026-10-29.

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

```jsx title="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>
  )
}
```

```jsx title="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](https://nisuform.com) 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](/blog/best-form-backend).

### 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](#a-nextjs-contact-form-with-zero-javascript).

### 3. A server action with Resend

This is what most Next.js tutorials and AI coding tools write:

```js title="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](/blog/react-contact-form#3-your-own-api-route-with-resend-or-nodemailer) 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](#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](https://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 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.

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

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

### 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](/docs/reference/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?](/blog/react-contact-form#why-does-my-react-form-clear-after-submit) explains it in detail.

### 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:

```ts title="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:

```js title="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 }
  }
}
```

```jsx title="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:

```jsx title="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](/docs/forms/after-submit), or add a `_redirect` field, to send visitors to your own page instead:

```jsx
<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](/docs/protection/access-rules).

If a form still gets hit, add [Cloudflare Turnstile](/docs/protection/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](/blog/html-forms-without-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](/blog/react-contact-form#react-18-contact-form-with-usestate-and-onsubmit) instead. |

In TypeScript, rename the file to `ContactForm.tsx` and type the state and the action:

```tsx
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](/blog/react-contact-form#react-contact-form-in-typescript). 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](/docs/integrations), or to your own code with a [webhook](/docs/integrations/webhooks), and send the visitor an [auto-reply](/docs/submissions/auto-replies). 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](/blog/react-contact-form) and the [Vue](/docs/frameworks/vue), [Svelte](/docs/frameworks/svelte) and [Astro](/docs/frameworks/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.
