Form Submission in Next.js 13

Here's how to submit a form to the server in Next.js 13.

The 'ol Server Render

What is old is new again. "We just released this progressive, inventive and amazing new release: Next 13! Now even React can do a server rendered HTML page."

Feels like React's Lost Decade. In 2014, Rails devs were saying the same thing. "We don't need a long-lived client app. We can do this simply, with the grain of the web. It's just a form submission."

For a decade, it's seemed like the default (sometimes only) option is to render a form with JS, track state in the browser, write a JSON API and make AJAX requests to it. (Except it is so common-place, we stopped calling it silly AJAX. That, or it was rarely XML to begin with.)

Nevertheless, now we can gratefully do a form submit to the server. On our new framework. Again. Let's remember some old patterns.

A Page

In Next, we have a page. Let's say that it's a page to submit a thing. The page component returns the rendered HTML.

export default async function FormPage(props) {
  return (/* ... */)
}

A Form

What's returned on the page? Just like in the old days, we have a form. Just HTML:

<form>
  <input type="text" name="thing" required />
  <button>Submit</button>
</form>

This form has an input for text. Importantly, its name attribute is present. When can use the built-in HTML validators for the form (ie, required).

Some reminders: A form has a default method: GET. A form has a default action, meaning where it's submitted to: /, or the current path.

Notably, there's no onSubmit handler in React. There's no event.preventDefault(). We're letting the form submit to the server and the browser to then refresh.

Detecting Submission

This is a GET request to the current path, /. In another web-rendered tech, you might check if the request was a POST and then know it was a form submission. In our case, we can't do a POST here with Next.

But, when we make a GET request from this form submission, all the keys and values of the named fields in the form get added to the query string. Those are available in props.searchParams. After a submission, that might look like:

{
  thing: 'swamp'
}

To detect submission, let's look at those params and see if a required key from the form is there:

function isSubmit(props) {
  return Object.keys(props.searchParams).includes('thing')
}

Where are we going to detect this? At the top of our component. Remember, submission will re-execute this component on the server.

import { redirect } from "next/navigation"

export default async function FormPage(props) {
  if (isSubmit(props)) {
    const result = await save(props.searchParams)
    if (result.ok) {
      return redirect('/list-things?success=Created'
    }  
  }
  return (/* ... */)
}

async function save(data) {
  return Promise.resolve('Imagine database persistence')
}

If it's a submission, we can take that data and save it, server-side.

And what's this redirect? Well, that's a pattern from the old days. After the successful submission on the form, you'd redirect back to a list view or a detailed view or something.

User Feedback

But what about errors in persistence? How will we give users feedback?

You could imagine some UI that renders from this data, perhaps a Toast component. It would be able to handle top-level messaging.

<Toast data={{ error }} />

Total Example

All together, here's the example:

import { redirect } from "next/navigation"

export default async function FormPage(props) {
  let error
  if (isSubmit(props)) {
    const result = await save(props.searchParams)
    if (result.ok) {
      return redirect('/list-things?success=Created'
    } else {
      error = result.error.message
    }
  }

  return (
    <div>
      <Toast data={{ error }} />
      <form>
        <input type="text" name="thing" required />
        <button>Submit</button>
      </form>
    </div>
  );
}

function isSubmit(props) {
  return Object.keys(props.searchParams).includes('thing')
}

async function save(data) {
  return Promise.resolve('Imagine database persistence')
}

function Toast() {
  return <div>Imagine a thing that temporarily shows a message</div>
}

What did we get? Great simplicity in the data flow. All state is ephemeral.

What did we lose? A no-refresh app. We haven't built per-field error feedback from the server. We could. We can't POST to pages in Next.

Depending on your requirements, it could be a good tradeoff. Feels pretty nice.