Multi-Step Forms in the App Router

How to split a long form into steps in the Next.js App Router: track the step, keep fields mounted with the hidden attribute, and submit once.

6 min read

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.

App.tsxApp.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.

typescripttypescript
// 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.tsxApp.tsx
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why does my first step's data disappear?

Conditional rendering unmounts the earlier step's inputs, so their values are removed from the form. Keep every step mounted and hide inactive ones with the hidden attribute.

Does each step need its own Server Action?

No. Use one form and one action for the whole flow, and submit only on the final step. Per-step actions are useful only when a step saves independently.

How do I validate each step?

Validate the current step on the client before advancing, and validate everything again in the Server Action on the final submit.

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.