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.