How to create a Next.js form
Build a Next.js form that collects submissions with no backend code. Covers the App Router (Server Actions + client components) and the Pages Router, with copy-paste examples.
This guide shows you how to build a Next.js contact form that collects submissions without writing or hosting any backend code. We’ll cover both modern approaches — the App Router (with a Server Action and a client component) and the older Pages Router — so you can pick the one that matches how your app is set up. The form sends data to a FormBackend endpoint, which stores every submission and emails you when one arrives.
Create your form endpoint in FormBackend
Go create a login and create a new form endpoint in FormBackend. Give it a name you can remember for example: “NextJS Contact Form” or something similar for this tutorial. It can always be changed later and is only used for you to remember your form.
Create a new Next.js app
If you don’t have an existing Next.js app, let’s create one real quick - if you do, then you can skip this section and go to the next one.
Make sure you have Node.js 18.18 or later installed. You can check with node -v and install it from nodejs.org if needed.
Let’s go ahead and create the Next.js app. Go to the directory where you want the
app to be created in your terminal. We’re going to use create-next-app via npx
to create the app.
npx create-next-app formbackend-nextjs
Let’s cd into the directory that was just created which contains our Next.js app.
cd formbackend-nextjs
We can start the development server with yarn run dev. That will start the
Next.js development server, which will automatically reload everytime you make any changes.
We can access our local Next.js app on http://localhost:3000. Visiting that URL
should show you the Next.js starter page and a big “Welcome to Next.js!” headline.
create-next-app uses the App Router (the app/ directory) by default. If your
project has a pages/ directory instead, you’re on the Pages Router — skip ahead
to the Pages Router walkthrough.
Next.js form with the App Router (recommended)
In the App Router there are two idiomatic ways to handle a form. Use whichever fits your needs.
Option A: a Server Action
Server Actions let you handle the submission on the server with no API route and no
client-side JavaScript. Create app/contact/actions.js:
"use server" export async function submitContact(formData) { await fetch("https://www.formbackend.com/f/your-form-id", { method: "POST", body: formData, headers: { accept: "application/json" }, }) }
Then create app/contact/page.js and pass the action straight to the form’s action prop:
import { submitContact } from "./actions" export default function ContactPage() { return ( <form action={submitContact}> <label>Name<input type="text" name="name" required /></label> <label>Email<input type="email" name="email" required /></label> <label>Message<textarea name="message" required /></label> <button type="submit">Send message</button> </form> ) }
Replace your-form-id with the endpoint URL from your form’s “Setup” tab. Because the
request happens on the server, your FormBackend URL never reaches the browser — and the
form keeps working even if JavaScript hasn’t loaded yet.
Option B: a client component with fetch
If you want inline success and error states without a page reload, use a client
component. Create app/contact/page.js:
"use client" import { useState } from "react" export default function ContactPage() { const [status, setStatus] = useState("idle") async function handleSubmit(event) { event.preventDefault() setStatus("submitting") const form = event.currentTarget const response = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { accept: "application/json" }, }) if (response.ok) { form.reset() setStatus("success") } else { setStatus("error") } } if (status === "success") { return <p>Thanks! Your message has been sent.</p> } return ( <form method="POST" action="https://www.formbackend.com/f/your-form-id" onSubmit={handleSubmit} > <label>Name<input type="text" name="name" required /></label> <label>Email<input type="email" name="email" required /></label> <label>Message<textarea name="message" required /></label> <button type="submit" disabled={status === "submitting"}> {status === "submitting" ? "Sending…" : "Send message"} </button> {status === "error" && <p>Something went wrong — please try again.</p>} </form> ) }
Using the browser’s built-in FormData keeps the inputs uncontrolled, so you don’t
need a separate piece of state for each field. The accept: application/json header
tells FormBackend to return JSON instead of a full HTML page.
That’s all you need for a working Next.js form. The detailed walkthrough below builds
the same thing step by step on the Pages Router with useState, in case that’s how
your app is set up or you want to understand each piece.
Pages Router: create the contact form page
Go ahead and create a new file named contact.js in the pages directory.
We’ll give it the following content for now:
import React, { useState } from "react" export default function Contact() { return ( <div> <h1>Contact form</h1> <form method="POST" action="https://www.formbackend.com/f/664decaabbf1c319"> <div> <label>Name</label> <input type="text" name="name" /> </div> <div> <label>Email</label> <input type="text" name="email" /> </div> <div> <label>Message</label> <textarea name="message"></textarea> </div> <button type="submit">Send message</button> </form> </div> ) }
This is a simple form with three form fields: name, email and message. You can
access the page if you visit http://localhost:3000/contact. Before doing anything
change the action attribute of the form tag, to the URL that matches the form
you created in FormBackend (you can find the url by visiting the “Setup”-tab).
Go ahead and visit the contact page you just created. Fill out the form and click the “Send message” button. You’re not presented with a “Thank you for your submission” page and you should be able to see the submission under submissions for your form in FormBackend.
Let us add a bit of javascript to this form
Let’s go ahead and make the experience a little nicer. We’re going to add a bit of
JavaScript and change how we handle the form state. Let’s start by adding some new
useState variables at the top of your page code just below the export default ... line:
export default function Contact() { const [formData, setFormData] = useState({ name: "", email: "", message: "" });
One thing to keep in mind here is: We want the name, email and message attributes
in the state to match the name tag of each field we added to the form. If we don’t do this
it won’t work!
Now we have a place to temporarily store the values that people enter in to your form before we submit it to FormBackend.
We need to trigger a function that does this when people enter data in the respective form fields.
On each of the three form fields, let’s add a onChange callback. Let’s look at the name field
as an example:
<input type="text" name="name" onChange="handleInput" />
Make sure you add onChange to the email and message fields as well!
Time to write out our handleInput function:
const handleInput = (e) => { const fieldName = e.target.name; const fieldValue = e.target.value; setFormData((prevState) => ({ ...prevState, [fieldName]: fieldValue })); }
Now every time someone writes something in the form fields, it updates the state representing the form data.
But how do we submit it to FormBackend you’re asking?
We need to add another callback to the form. This time it’s the onSubmit callback:
<form method="POST" action="[your-unique-url]" onSubmit={submitForm}>
Let’s write out the submitForm function:
const submitForm = (e) => { // We don't want the page to refresh e.preventDefault() const formURL = e.target.action const data = new FormData() // Turn our formData state into data we can use with a form submission Object.entries(formData).forEach(([key, value]) => { data.append(key, value); }) // POST the data to the URL of the form fetch(formURL, { method: "POST", body: data, headers: { 'accept': 'application/json', }, }).then(() => { setFormData({ name: "", email: "", message: "" }) }) }
Let us take a closer look at what’s going on in the above code.
The e.preventDefault() line, tells the form to not do it’s default behavior which is to submit and refresh the page.
We then store the value of the action attribute on the form (the FormBackend URL) in the formURL variable.
We need a way to take our formData state and turn that in to a proper FormData object that the browser can use when submitting
the form to FormBackend. We do that by generating a new FormData() object and assigning that to the data variable. We then iterate over
all the values in our formData state object and append that to the FormData assigned to data.
Now it’s time to submit the form, for that we’re going to use fetch which is in all modern browsers. We set the method to POST as
that is what FormBackend expects (and the right way to send data over the wire). We assign our FormData object data to the body
of the request and set the accept header to application/json as we want FormBackend to return json and not the full HTML page in the response.
Once the form has been submitting successfully, then() will get run, inside of that we reset our formData state object by setting all
the field values to blank. We need to add one small thing for this to work properly. For each of our fields we want to bind the value to their respective
value in our formData state object. Like so:
<div> <label>Name</label> <input type="text" name="name" value={formData.name} /> </div> <div> <label>Email</label> <input type="text" name="email" value={formData.email} /> </div> <div> <label>Message</label> <textarea name="message" value={formData.message}></textarea> </div>
Now when we submit the form, we send all the data to FormBackend, we reset our formData values and you’ll see the form be reset to all empty values again.
Show a message once the form has been submitted
It can be a little jarring to submit a form, and see all the fields reset without any confirmation of what just happened. So let’s display a small message on the page and hide the form once it has been submitted successfully.
In order to do so we need to introduce a few extra things in our Next.js contact form. First let’s introduce a new variable named formSuccess using useState. Towards the top of the file
add the following:
const [formSuccess, setFormSuccess] = useState(false)
This new state will keep track of if the Next.js form has been submitted successfully. So inside of the then() callback in our formSubmit function let’s add setFormSuccess(true) right after setFormData(...) so it’ll look like this:
}).then(() => { setFormData({ name: "", email: "", message: "" }) setFormSuccess(true) })
We will now add a bit of logic to our HTML by using the conditional (ternary) operator:
return ( <div> <h1>Contact form</h1> {formSuccess ? <div>Form submitted</div> : <form method="POST" action="https://www.formbackend.com/f/664decaabbf1c319" onSubmit={submitForm}> <div> <label>Name</label> <input type="text" name="name" onChange={handleInput} value={formData.name} /> </div> <div> <label>Email</label> <input type="text" name="email" onChange={handleInput} value={formData.email} /> </div> <div> <label>Message</label> <textarea name="message" onChange={handleInput} value={formData.message}></textarea> </div> <button type="submit">Send message</button> </form> } </div> )
Notice towards the top how we check if formSucess is true and if it is we display Form submitted if not, we display the form. That’ll make it so when you submit the form
we hide the form itself and show Form submitted.
Use the submission message returned by the server
We can make this even more dynamic and use the submittion text returned by the server. Make the following change around the then() line:
}).then((response) => response.json()) .then((data) => {
We will now parse the server response as JSON which we can access via the data attribute. Let’s introduce a new piece of state which we’ll call formSuccessMessage. Towards the top
of the file we’ll add:
const [formSuccessMessage, setFormSuccessMessage] = useState("")
We will then change our “Form submitted” message to <div>{formSuccessMessage} and below our setFormData reset in the then callback for fetch - we’ll add:
setFormSuccessMessage(data.submission_text)
The JSON returned by FormBackend when you submit the Next.js form using JavaScript contains the following:
{ submission_text: "Thank you for your submission", redirect_url: null, errors: [], values: { name: "John Doe", email: "hello@formbackend.com", message: "My message" } }
Where values is the submitted values from our form and submission_text is the text we have entered on the “Settings” page for your form (default is: Thank you for your submission)
The final result
The final version of our contact.js Next.js page looks like this:
import React, { useState } from "react" export default function Contact() { const [formData, setFormData] = useState({ name: "", email: "", message: "" }); const [formSuccess, setFormSuccess] = useState(false) const [formSuccessMessage, setFormSuccessMessage] = useState("") const handleInput = (e) => { const fieldName = e.target.name; const fieldValue = e.target.value; setFormData((prevState) => ({ ...prevState, [fieldName]: fieldValue })); } const submitForm = (e) => { // We don't want the page to refresh e.preventDefault() const formURL = e.target.action const data = new FormData() // Turn our formData state into data we can use with a form submission Object.entries(formData).forEach(([key, value]) => { data.append(key, value); }) // POST the data to the URL of the form fetch(formURL, { method: "POST", body: data, headers: { 'accept': 'application/json', }, }).then((response) => response.json()) .then((data) => { setFormData({ name: "", email: "", message: "" }) setFormSuccess(true) setFormSuccessMessage(data.submission_text) }) } return ( <div> <h1>Contact form</h1> {formSuccess ? <div>{formSuccessMessage}</div> : <form method="POST" action="https://www.formbackend.com/f/664decaabbf1c319" onSubmit={submitForm}> <div> <label>Name</label> <input type="text" name="name" onChange={handleInput} value={formData.name} /> </div> <div> <label>Email</label> <input type="text" name="email" onChange={handleInput} value={formData.email} /> </div> <div> <label>Message</label> <textarea name="message" onChange={handleInput} value={formData.message}></textarea> </div> <button type="submit">Send message</button> </form> } </div> ) }
That is how you create a simple contact form for Next.js and gradually make it more advanced using FormBackend. The code can be found on GitHub.
Configure notifications and integrations
Your Next.js form is now collecting submissions. Here are some things to set up in FormBackend:
- Email notifications: Get notified every time someone submits, with optional file attachments
- Auto-reply emails: Automatically confirm receipt to the person who submitted
- Spam protection: Built-in spam filtering is active by default. Add Cloudflare Turnstile for extra security
- Integrations: Route submissions to Slack, Google Sheets, Notion, or any URL via webhooks
- Redirect after submission: Send users to a specific page on your site with submitted values in the URL
Want to add forms to a different framework? Check out our guides for React, Vue.js, Astro, Nuxt, and more.
Add a form backend to your site in minutes
Connect any HTML form to FormBackend and start collecting submissions — no backend code required.
Start free