Skip to main content
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 is free.
  • A form in the dashboard. 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:
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.
The same request with Axios is on the React page.
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:
Then read it in the component:
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:
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:
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):
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.
Files are stored on paid plans only. Free plans store the rest of the submission without them.

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:
Put the request in a file marked "use server":
Then call the action from the client component in place of fetch:
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.
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.

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: 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:
With reCAPTCHA v3 there is no widget. Wrap the form in the provider, call executeRecaptcha() when the visitor submits, and send what it returns:
With hCaptcha, the widget passes the token to onVerify:
With Turnstile, to onSuccess:
When the post goes through a Server Action, the widget stays in the client component and the token travels with the fields:
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:
It works through the Server Action too, because the action returns formcarry’s answer unchanged:

What’s next

Stuck? Write to help@formcarry.com. Include the form id.