Back to blog
guides

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.

J
Jesper Christiansen
Header graphic that shows the Next.js logo

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.

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:

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