Build Appeal
Multi-step intake form with per-step validation. Formik state machine on Next.js, Tailwind UI, no backend.
Multi-step intake, no backend required
- TypeScript
- Next.js
- React
- Formik
- TailwindCSS

Why I built this
Multi-step forms turn out to be more annoying than they look. They always start small and then either become a wall of inputs nobody completes or a custom state machine nobody can edit. I wanted to see how clean the multi-step pattern could be in Next.js without reaching for a form-engine framework.
The brief was specific. A series of radio-only steps with a landing page, a navbar, progress between steps, and a clean success state. No backend.
What it does
- A landing page with a clear CTA into the form.
- A navbar that persists across steps.
- A multi-step radio form. One question per step, progress visible, back / next that doesn’t lose state.
- A success page that confirms completion.
- Tailwind-styled with consistent spacing and typography.
- Live: build-appeal-kappa.vercel.app.
Architecture
A single Formik provider wraps the form shell. Each step is its own component that reads and writes a slice of the same Formik state. Navigation between steps is route-based using Next.js pages, so the URL reflects the current step and a refresh keeps the progress.
Technical decisions
Formik over React Hook Form. Both work. Formik’s <Field> /
<ErrorMessage> ergonomics matched the radio-heavy shape better.
RHF is better when you have lots of inputs with custom
controllers.
Route-based steps, not in-component state. The browser back button works correctly. Refresh keeps progress. Screen-reader users get a real navigation event between steps.
Tailwind from the start. No CSS modules, no styled-components. The form chrome is small enough that utility classes are the shortest path.
No backend. The success page is the contract surface. Once there’s a real ingest, the success handler becomes a POST. The form itself doesn’t need to know.
import { Field, useFormikContext } from "formik";
import { useRouter } from "next/router";
export function StepOne() {
const { values } = useFormikContext<FormValues>();
const router = useRouter();
return (
<fieldset>
<legend className="text-lg font-medium">Type of appeal</legend>
<Field type="radio" name="appealType" value="planning" />
<Field type="radio" name="appealType" value="rates" />
<Field type="radio" name="appealType" value="other" />
<button
type="button"
disabled={!values.appealType}
onClick={() => router.push("/step/2")}
className="mt-6 inline-flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-white"
>
Continue →
</button>
</fieldset>
);
}
What I learned
Routing-as-state is underrated for forms. URL-driven progress gives you refresh recovery, deep links to specific steps, and shareable error states for free.
When you add Yup validation, it should live next to the schema, not next to the field. One source of truth per step.
Tailwind’s disabled: variant covers most of the
“can’t-go-to-next-step-yet” UX without custom button states.
Next steps
- Add Yup validation per step, with errors rendered next to the active radio group.
- Persist progress to
localStorageso a return visitor lands on the step they left. - Add a server action or API route on the success path so the intake actually goes somewhere.
- Pull the FormShell + step pattern into a small reusable kit so the next intake form is a config, not a rewrite.
Source: github.com/uzairali19/scroll-quiz.