# Send Next.js form submissions to email without an API route

A client component posts your Next.js form to an endpoint that stores each submission and emails you. Works on Vercel, in static exports and in both routers.

Updated Oct 29, 2026.

Most Next.js contact form tutorials add a route handler or a server action that calls an email API. You can skip both. A client component can post the form straight to a Nisuform endpoint, which filters spam, stores the submission and emails you. Because nothing runs on your server, the same form works on Vercel, in a static export and on any other host.

## The component

```tsx title="app/contact/ContactForm.tsx"
'use client'

import { useRef, useState, type FormEvent } from 'react'

const endpoint = process.env.NEXT_PUBLIC_FORM_ENDPOINT ?? 'https://api.nisuform.com/s/YOUR_FORM_KEY'

export function ContactForm() {
  const shownAt = useRef(Date.now())
  const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
  const [error, setError] = useState('')

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const data = new FormData(event.currentTarget)
    data.set('_elapsed', String(Date.now() - shownAt.current))
    setStatus('sending')
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: data,
      })
      const result = await response.json()
      setStatus(result.ok ? 'sent' : 'error')
      setError(result.ok ? '' : result.message)
    } catch {
      setStatus('error')
      setError('Something went wrong. Please try again.')
    }
  }

  if (status === 'sent') {
    return <p role="status">Thanks! We got your message.</p>
  }

  return (
    <form onSubmit={onSubmit}>
      <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" disabled={status === 'sending'}>
        {status === 'sending' ? 'Sending...' : 'Send'}
      </button>
      {status === 'error' && <p role="alert">{error}</p>}
    </form>
  )
}
```

The page that renders it stays a server component, and only the form ships JavaScript:

```tsx title="app/contact/page.tsx"
import { ContactForm } from './ContactForm'

export const metadata = { title: 'Contact' }

export default function ContactPage() {
  return (
    <main>
      <h1>Contact</h1>
      <ContactForm />
    </main>
  )
}
```

The Pages Router ignores the `'use client'` line, so the same file works there. [Create a form](https://app.nisuform.com) to get your endpoint, then put it in the variable below or straight into the file.



## When a server action is worth it

Use a server action when your server needs each message, for example to save it in your own database. The action can still forward the form to the endpoint, but Nisuform then sees your server instead of the visitor. Rate limits count your server's IP address, allowed websites have no `Origin` to check, and the fill-time check is skipped. The [Next.js contact form guide](/blog/nextjs-contact-form) shows that version and what it costs. A static export can't run server actions at all.

## The endpoint as an environment variable

The form key is public, so a variable isn't about secrecy. It lets preview deployments post to a test form instead of your real one:

```sh title=".env.local"
NEXT_PUBLIC_FORM_ENDPOINT=https://api.nisuform.com/s/YOUR_FORM_KEY
```

Next.js inlines `NEXT_PUBLIC_` values into the browser bundle at build time. Set the same variable in your host's project settings and redeploy, because a new value only shows up after the next build.

## Content Security Policy

If your app sends a `Content-Security-Policy` header, from `next.config.ts` or middleware, the browser blocks requests the policy doesn't allow. Add the endpoint's origin to two directives:

```text title="Content-Security-Policy"
connect-src 'self' https://api.nisuform.com;
form-action 'self' https://api.nisuform.com;
```

`connect-src` covers `fetch`. `form-action` covers forms that post with a plain `action` attribute, including the fallback before JavaScript loads. A blocked request shows up in the console as a CSP violation, not as a Nisuform error.

## Hosting

Vercel has no forms feature, so on Vercel this is the simplest way to get one. The form never touches your Vercel functions, so it adds nothing to their usage. With `output: 'export'` the component works unchanged, and you can host the `out` folder on GitHub Pages, Cloudflare Pages or S3.

Once the site is live, add its domain to the form's [allowed websites](/docs/protection/access-rules), plus `http://localhost:3000` for development.

## Guides

- [Next.js form component](https://nisuform.com/docs/frameworks/nextjs)
- [Next.js contact form: client component vs server action](https://nisuform.com/blog/nextjs-contact-form)

## Frequently asked questions

### Do I need an API route to send email from a Next.js form?

No. A client component can post the form to a form backend endpoint, which stores the submission and emails you. Add a route handler or server action only if your own server has to do something with each message, like saving it to your database.

### Does Vercel have a forms feature?

No. Unlike Netlify, Vercel has no built-in form handling or email sending. A Next.js form on Vercel either calls your own route handler with an email provider, or posts to a form backend.

### Does it work with output export?

Yes. A static export can't run route handlers or server actions, but a client component that posts to an endpoint is plain browser code. It works on GitHub Pages, Cloudflare Pages, S3 or any other static host.

### Should the endpoint be an environment variable?

It can be, but it isn't a secret. The form key only accepts new submissions, so it is safe in client code. If you use a variable, prefix it with NEXT_PUBLIC_ so Next.js inlines it into the browser bundle.

### Why does my form fail with a Content Security Policy error?

Your CSP blocks requests to other origins. Add https://api.nisuform.com to connect-src for fetch, and to form-action if a form posts with a plain action attribute.
