> ## Documentation Index
> Fetch the complete documentation index at: https://formcarry.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Next.js

> Next.js sends a form to formcarry with one fetch call from a client component, in the App Router or the Pages Router.

The component keeps the fields in state, posts them as JSON and shows formcarry's answer, and the same request carries files, a spam blocker token and validation errors, from the browser or from a Server Action.

## Prerequisites

Before you start, you need:

* A formcarry account. [Sign up](https://app.formcarry.com/register) is free.
* A form in the [dashboard](https://app.formcarry.com). Its endpoint is on the form's Setup page. The examples use `https://formcarry.com/s/AbC123xyz`; put yours in its place.

## The component

To send a form from the App Router, mark the file as a client component, post the fields as JSON, and send `Accept: application/json` so the answer comes back as JSON:

```jsx theme={null}
"use client"

import { useState } from "react"

export default function ContactForm() {
  const [fields, setFields] = useState({ email: "", message: "" })
  const [sent, setSent] = useState(false)
  const [error, setError] = useState("")

  function update(e) {
    setFields({ ...fields, [e.target.name]: e.target.value })
  }

  async function submit(e) {
    e.preventDefault()
    const res = await fetch("https://formcarry.com/s/AbC123xyz", {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify(fields),
    })
    const data = await res.json()
    if (data.code === 200) setSent(true)
    else setError(data.message)
  }

  if (sent) return <p>Thanks, we got your message.</p>

  return (
    <form onSubmit={submit}>
      <input name="email" type="email" value={fields.email} onChange={update} />
      <textarea name="message" value={fields.message} onChange={update} />
      <button type="submit">Send</button>
      {error && <p>{error}</p>}
    </form>
  )
}
```

The keys of the JSON body are the field names formcarry stores.

The `email` key is the visitor's address, so it becomes the reply-to address of your notification email and the recipient of the auto response. A stored submission answers with `code: 200`. A refused one answers with the reason in `message`, see [What every form needs](/docs/what-every-form-needs).

<Note>
  The same request with Axios is on the [React](/docs/frameworks/react) page.
</Note>

Put `"use client"` on the first line rather than leaving it out, otherwise the App Router renders the file as a Server Component, where `useState` is not available. In the Pages Router the same component works without that line.

To read the endpoint from the environment instead of the source, put it in `.env.local` with the `NEXT_PUBLIC_` prefix:

```text theme={null}
NEXT_PUBLIC_FORMCARRY_ENDPOINT=https://formcarry.com/s/AbC123xyz
```

Then read it in the component:

```jsx theme={null}
const res = await fetch(process.env.NEXT_PUBLIC_FORMCARRY_ENDPOINT, {
  method: "POST",
  headers: { "Accept": "application/json", "Content-Type": "application/json" },
  body: JSON.stringify(fields),
})
```

Name it `NEXT_PUBLIC_FORMCARRY_ENDPOINT` rather than `FORMCARRY_ENDPOINT`, otherwise the browser reads `undefined` and the request never reaches formcarry. Next.js inlines only `NEXT_PUBLIC_` variables into the browser bundle, and this request runs in the browser.

## Files

To send files, build a `FormData` from the form element in the client component and leave the content type to the browser:

```jsx theme={null}
async function submit(e) {
  e.preventDefault()
  const res = await fetch("https://formcarry.com/s/AbC123xyz", {
    method: "POST",
    headers: { "Accept": "application/json" },
    body: new FormData(e.currentTarget),
  })
  const data = await res.json()
}
```

Every input needs a `name`, including the file input, because `FormData` reads them by name. Send `Accept` and nothing else rather than adding `Content-Type` yourself, otherwise the multipart boundary is missing and the upload fails.

For several files, or for `File` objects that a dropzone library hands you, append each one under its own name:

```jsx theme={null}
const data = new FormData()
data.append("email", fields.email)
files.forEach((file, i) => data.append(`file-${i}`, file, file.name))
```

For small files you can stick with JSON and send the file as a data URL. Read it with `FileReader`, and give each file its own key (never an array):

```jsx theme={null}
const toDataUrl = (file) =>
  new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = reject
    reader.readAsDataURL(file)
  })

body: JSON.stringify({ ...fields, attachment: await toDataUrl(file) })
```

Use data URLs for small files. Using them with larger files bloats the request by about a third and reaches the 50 MB limit sooner.

<Note>
  Files are stored on paid plans only. Free plans store the rest of the submission without them.
</Note>

## Server Action

A Server Action posts from your server, so the endpoint stays out of the browser bundle. Put the endpoint in `.env.local` without the prefix:

```text theme={null}
FORMCARRY_ENDPOINT=https://formcarry.com/s/AbC123xyz
```

Put the request in a file marked `"use server"`:

```js theme={null}
// app/actions.js
"use server"

export async function sendContact(fields) {
  const res = await fetch(process.env.FORMCARRY_ENDPOINT, {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(fields),
  })
  return res.json()
}
```

Then call the action from the client component in place of `fetch`:

```jsx theme={null}
"use client"

import { sendContact } from "./actions"

async function submit(e) {
  e.preventDefault()
  const data = await sendContact(fields)
  if (data.code === 200) setSent(true)
  else setError(data.message)
}
```

The action runs on the server, so its variable doesn't need the `NEXT_PUBLIC_` prefix. The endpoint is **not** a secret. Any HTML form that posts to it already shows the endpoint in its `action`, so posting from the server doesn't make it any safer.

Because of this, every submission now leaves from your server's IP address. The rate limit (1 submission per 15 seconds per form per IP address) applies to all of your visitors **together** as one shared limit.  If two users submit within 15 seconds of each other, the second gets a `429`. The spam blocker token is still produced in the browser, so the client component still renders the challenge and passes the token to the action.

<Tip>
  We recommend posting from the browser for public forms. The limit then applies to each visitor on their own, giving them their own 15-second window.
</Tip>

## Spam blocker

To add a challenge, render its widget in the client component, send the token under the name formcarry reads, and paste the secret key into the form's settings under Form Security:

| Challenge    | Package the React demo uses | Token field             |
| ------------ | --------------------------- | ----------------------- |
| reCAPTCHA v2 | `react-google-recaptcha`    | `g-recaptcha-response`  |
| reCAPTCHA v3 | `react-google-recaptcha-v3` | `g-recaptcha-response`  |
| hCaptcha     | `@hcaptcha/react-hcaptcha`  | `h-captcha-response`    |
| Turnstile    | `@marsidev/react-turnstile` | `cf-turnstile-response` |

With reCAPTCHA v2, put the site key in `.env.local` as `NEXT_PUBLIC_RECAPTCHA_SITE_KEY`, read it the same way as the endpoint, and send nothing while the token is empty:

```jsx theme={null}
import ReCAPTCHA from "react-google-recaptcha"

const [token, setToken] = useState("")

<ReCAPTCHA sitekey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY} onChange={setToken} />

async function submit(e) {
  e.preventDefault()
  if (!token) {
    setError("Complete the challenge first")
    return
  }
  const res = await fetch(process.env.NEXT_PUBLIC_FORMCARRY_ENDPOINT, {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({ ...fields, "g-recaptcha-response": token }),
  })
}
```

With reCAPTCHA v3 there is no widget. Wrap the form in the provider, call `executeRecaptcha()` when the visitor submits, and send what it returns:

```jsx theme={null}
"use client"

import { GoogleReCaptchaProvider, useGoogleReCaptcha } from "react-google-recaptcha-v3"

export default function ContactPage() {
  return (
    <GoogleReCaptchaProvider reCaptchaKey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}>
      <ContactForm />
    </GoogleReCaptchaProvider>
  )
}

function ContactForm() {
  const { executeRecaptcha } = useGoogleReCaptcha()

  async function submit(e) {
    e.preventDefault()
    const token = await executeRecaptcha("contact")
    const res = await fetch(process.env.NEXT_PUBLIC_FORMCARRY_ENDPOINT, {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify({ ...fields, "g-recaptcha-response": token }),
    })
  }
}
```

With hCaptcha, the widget passes the token to `onVerify`:

```jsx theme={null}
import HCaptcha from "@hcaptcha/react-hcaptcha"

<HCaptcha sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY} onVerify={setToken} />

body: JSON.stringify({ ...fields, "h-captcha-response": token })
```

With Turnstile, to `onSuccess`:

```jsx theme={null}
import { Turnstile } from "@marsidev/react-turnstile"

<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} onSuccess={setToken} />

body: JSON.stringify({ ...fields, "cf-turnstile-response": token })
```

When the post goes through a Server Action, the widget stays in the client component and the token travels with the fields:

```jsx theme={null}
const data = await sendContact({ ...fields, "g-recaptcha-response": token })
```

The widgets run in the browser, so they render in a client component whichever way you post. Refuse to send while the token is empty rather than posting anyway, otherwise formcarry answers `403` for reCAPTCHA and `400` for the others. Add `localhost` to the challenge's allowed domains while you test.

## Validation errors

A `422` carries `errors`, one entry per failing field, each with a `message`. To show each one next to its field, keep `errors` in state and check for `422` before the general error:

```jsx theme={null}
const [fieldErrors, setFieldErrors] = useState({})

const data = await res.json()
if (data.code === 200) setSent(true)
else if (data.code === 422) setFieldErrors(data.errors)
else setError(data.message)

{fieldErrors.email && <p>{fieldErrors.email.message}</p>}
```

It works through the Server Action too, because the action returns formcarry's answer unchanged:

```jsx theme={null}
const data = await sendContact(fields)
if (data.code === 422) setFieldErrors(data.errors)
```

## What's next

* [Field validations](/docs/features/field-validations): the rules you can set per field.
* [Spam protection](/docs/features/spam-protection): the challenges, the filter and the honeypot.
* [Email notifications](/docs/features/email-notifications): the auto response the visitor gets, keyed on `email`.

Stuck? Write to [help@formcarry.com](mailto:help@formcarry.com). Include the form id.


## Related topics

- [v0](/docs/ai/v0.md)
- [Introduction](/docs/introduction.md)
- [formcarry.js](/docs/frameworks/formcarry-js.md)
- [Vue](/docs/frameworks/vue.md)
- [JavaScript](/docs/frameworks/javascript.md)
