Multi-step forms in the App Router split a long form into sections and show one at a time. The steps are client-side state, while the final submit still runs one Server Action, so the pattern does not change how data reaches the server.
Track the current step with useState, and render every step inside the same form. Inactive steps stay mounted but hidden, so the values a visitor already typed are not lost. Save this as app/signup-form.tsx.
'use client'
import { useState } from 'react'
import { signUp } from './actions'
export function SignupForm() {
const [step, setStep] = useState(1)
return <form action={signUp}>
<label hidden={step !== 1}>Name <input name="name" /></label>
<label hidden={step !== 2}>Email <input name="email" /></label>
{step === 1 ? <button type="button" onClick={() => setStep(2)}>Next</button> : <button>Submit</button>}
</form>
}The hidden attribute is the key line. It hides a step without removing its inputs from the form, so the name typed on step one is still submitted when step two finishes. The Next button is type button so it advances the step instead of submitting.
// app/actions.ts
'use server'
import { db } from '@/lib/db'
export async function signUp(formData: FormData) {
const name = String(formData.get('name') ?? '')
const email = String(formData.get('email') ?? '')
if (!name || !email.includes('@')) return
await db.user.create({ data: { name, email } })
}The action reads both fields on the final submit, no matter which step was visible when the visitor finished. It re-checks both, because a visitor who skipped step one still reaches the same endpoint.
Keep every step mounted
Conditional rendering with a ternary unmounts the hidden step, and unmounted inputs do not submit. That is the mistake readers hit most often: the flow looks right, but the first step's data silently disappears.
Use the hidden attribute instead, or store the collected values in state and pass them to the action explicitly. Hiding is simpler and keeps the form HTML-driven, which is why most multi-step forms in the App Router are built this way.
The hidden attribute keeps a field in the document but removes it from view, and a hidden field's value is still part of the form submission. That is exactly the behavior a multi-step form needs.
Show progress accessibly
Give the form an accessible progress signal. A short text element that names the current step tells screen reader users where they are, and an aria-live region announces the change when the step advances.
// app/step-progress.tsx
export function StepProgress({ step, total }: { step: number; total: number }) {
return <p aria-live="polite">Step {step} of {total}</p>
}Render it as the first child of the form and pass the current step. Because the region is polite, the announcement waits for a pause rather than interrupting whatever the visitor is reading.
The hidden attribute removes a step from the accessibility tree as well as from view, so screen reader users never encounter fields from an inactive step. That is the behavior you want here, but it also means focus has to be moved deliberately: after advancing, focus the first field of the new step so keyboard users do not tab from the top again.
Validate per step
Check the current step's fields on the client before advancing, so a visitor cannot skip a broken step. A simple check in the Next button handler is enough for short flows.
Validate everything again inside the Server Action, because the action is still reachable directly over HTTP. The client check is convenience; the server check is the guard.
Multi-step forms make that gap easy to forget, since the visitor appears to have passed every step on the way through. They did not have to: a single POST carrying both fields reaches the same action without touching the Next button at all.
See building forms in Next.js with Server Actions for the single-form pattern, and showing pending and success states with useFormStatus for the submit button.
Rune AI
Key Insights
- Track the current step with useState.
- Keep all steps mounted and hide them with the hidden attribute.
- Submit once to a single Server Action on the last step.
- Validate each step before advancing and again on the server.
- Announce step changes for screen readers.
Frequently Asked Questions
Why does my first step's data disappear?
Does each step need its own Server Action?
How do I validate each step?
Conclusion
A multi-step form in the App Router is a single form whose steps are toggled with client state. Keep every field mounted and hide inactive steps with the hidden attribute, then submit once to a Server Action on the last step.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.