App Router - Code Examples
📘 NOTE: For App Router applications, import from
'@krakentech/blueprint-onboarding/next-wizard/app-router'.
Here are some examples of how we can use the createNextWizard() builder function to create a workflow of pages in Next.js App Router applications.
Create Wizard
The createNextWizard function provides a set of strongly typed utils designed for the App Router. Unlike the Pages Router version, it uses next/headers for cookie operations and doesn't require a separate API route.
⚠️ NOTE ⚠️: Avoid using file-scoped constants for mutable state, as these instances will be shared across all users due to the serverless nature of Next.js. Instead, always initialize state using a function to ensure a fresh object is created for each request.
// lib/onboarding/wizard.ts
import {
createNextWizard,
NextWizard
} from '@krakentech/blueprint-onboarding/next-wizard/app-router';
export const getInitialFormValues = (): OnboardingFormValues => ({
[OnboardingKey.Postcode]: '',
[OnboardingKey.TariffSelection]: '',
// ...
});
export const getInitialState = (): OnboardingState => ({
formValues: getInitialFormValues(),
submittedSteps: [],
});
export type OnboardingNextWizard = NextWizard<
typeof OnboardingStep,
OnboardingState,
OnboardingSerializedState
>;
export const wizard: OnboardingNextWizard = createNextWizard({
cookieName: 'example-onboarding',
encryptionSecret: process.env.ENCRYPTION_SECRET,
initialState: getInitialState(),
steps,
serializeState,
deserializeState,
});
Building a Server Component Page
In the App Router, you use Server Components to fetch the wizard state. The wizard's cookie methods use next/headers internally.
// app/onboarding/tariffs/page.tsx
import { redirect } from 'next/navigation';
import { wizard } from '@/lib/onboarding/wizard';
import { OnboardingStep } from '@/lib/onboarding/steps';
import { TariffsClient } from './TariffsClient';
export default async function TariffsPage() {
const state = await wizard.getCookieState();
// Verify step is unlocked
const isUnlocked = wizard.getIsStepUnlocked({
stepName: OnboardingStep.Tariffs,
state,
submittedSteps: state.submittedSteps,
});
if (!isUnlocked) {
// Use redirectToStep which throws a redirect
wizard.redirectToStep({
state,
submittedSteps: state.submittedSteps,
});
}
// Serialize state for client component
return <TariffsClient serializedState={wizard.serializeState(state)} />;
}
Client Component with Form
For client-side interactions, you'll use useRouter from next/navigation combined with the wizard's step utilities.
// app/onboarding/tariffs/TariffsClient.tsx
'use client';
import { useRouter } from 'next/navigation';
import { Formik, Form } from 'formik';
import { wizard, deserializeState } from '@/lib/onboarding/wizard';
import { OnboardingStep } from '@/lib/onboarding/steps';
import { submitStep } from './actions';
export function TariffsClient({
serializedState,
}: {
serializedState: OnboardingSerializedState;
}) {
const state = deserializeState(serializedState);
const router = useRouter();
return (
<Formik
initialValues={state.formValues}
onSubmit={async (values) => {
const newState: OnboardingState = {
...state,
formValues: values,
submittedSteps: wizard.addToSubmittedSteps({
stepName: OnboardingStep.Tariffs,
submittedSteps: state.submittedSteps,
}),
};
// Use Server Action to persist state
await submitStep(newState);
// Get next step and navigate
const nextStep = wizard.getNextStep({
currentStepName: OnboardingStep.Tariffs,
state: newState,
});
router.push(wizard.getStepDestination(nextStep));
}}
>
<Form>...</Form>
</Formik>
);
}
Server Action for Cookie State
Instead of an API route, the App Router version uses Server Actions to persist the cookie state.
// app/onboarding/tariffs/actions.ts
'use server';
import { wizard } from '@/lib/onboarding/wizard';
import type { OnboardingState } from '@/lib/onboarding/types';
export async function submitStep(state: OnboardingState) {
await wizard.setCookieState({ state });
}
export async function clearWizard() {
await wizard.deleteCookieState();
}
Alternative: Inline Server Action
You can also define the server action inline if you prefer:
// app/onboarding/tariffs/page.tsx
import { redirect } from 'next/navigation';
import { wizard } from '@/lib/onboarding/wizard';
import { TariffsForm } from './TariffsForm';
export default async function TariffsPage() {
const state = await wizard.getCookieState();
// Server action for form submission
async function submitAction(formData: FormData) {
'use server';
// Option 1: Simple extraction (not fully type-safe)
const tariffSelection = formData.get('tariff_selection') as string;
// Option 2: Yup validation
// const schema = yup.object({ tariff_selection: yup.string().required() });
// const { tariff_selection: tariffSelection } = await schema.validate({
// tariff_selection: formData.get('tariff_selection'),
// });
// Option 3: Zod validation
// const schema = z.object({ tariff_selection: z.string().min(1) });
// const { tariff_selection: tariffSelection } = schema.parse({
// tariff_selection: formData.get('tariff_selection'),
// });
const newState: OnboardingState = {
...state,
formValues: {
...state.formValues,
[OnboardingKey.TariffSelection]: tariffSelection,
},
submittedSteps: wizard.addToSubmittedSteps({
stepName: OnboardingStep.Tariffs,
submittedSteps: state.submittedSteps,
}),
};
await wizard.setCookieState({ state: newState });
const nextStep = wizard.getNextStep({
currentStepName: OnboardingStep.Tariffs,
state: newState,
});
redirect(wizard.getStepDestination(nextStep));
}
return <TariffsForm state={state} submitAction={submitAction} />;
}
Navigation Without Router Helpers
Unlike the Pages Router version, the App Router version does not include toNextStep, toPreviousStep, or toStep methods. Instead, you combine getStepDestination() with useRouter from next/navigation:
'use client';
import { useRouter } from 'next/navigation';
import { wizard } from '@/lib/onboarding/wizard';
function NavigationButtons({ state, currentStep }) {
const router = useRouter();
const handleNext = () => {
const nextStep = wizard.getNextStep({
currentStepName: currentStep,
state,
});
router.push(wizard.getStepDestination(nextStep));
};
const handlePrevious = () => {
const previousStep = wizard.getPreviousStep({
currentStepName: currentStep,
state,
});
router.push(wizard.getStepDestination(previousStep));
};
const handleGoToStep = (stepName) => {
const step = wizard.getStep({
stepName,
state,
});
router.push(wizard.getStepDestination(step));
};
return (
<div>
<button onClick={handlePrevious}>Back</button>
<button onClick={handleNext}>Next</button>
</div>
);
}
Stepper
A stepper can easily be setup using the generic utils, similar to the Pages Router version:
'use client';
import { useRouter } from 'next/navigation';
import { useMemo } from 'react';
import { wizard } from '@/lib/onboarding/wizard';
const OnboardingStepper = ({ state, stepName }) => {
const router = useRouter();
const stepperProps = useMemo(() => {
const currentStep = wizard.getStep({ state, stepName });
const steps = wizard.getSteps(state);
const lowestUnsubmittedStepIndex = steps.findIndex(
(step) => !state.submittedSteps.includes(step.name)
);
const totalSteps = steps.length;
const activeStep = steps.findIndex(
(step) => step.name === currentStep.name
) + 1;
const stepConfigs = steps.map((step, i) => ({
title: step.name,
'aria-label': step.name,
disabled:
lowestUnsubmittedStepIndex !== -1 &&
i > lowestUnsubmittedStepIndex,
}));
const onStepClicked = async (stepNumber: number) => {
const targetStep = steps[stepNumber - 1];
router.push(wizard.getStepDestination(targetStep));
};
return { totalSteps, stepConfigs, onStepClicked, activeStep };
}, [router, state, stepName]);
return <Stepper {...stepperProps} />;
};
Migration from Pages Router
If you're migrating from Pages Router to App Router, here are the key changes:
| Pages Router | App Router |
|---|---|
Import from .../next-wizard/pages-router | Import from .../next-wizard/app-router |
getCookieStateOnServer(context) | getCookieState() |
setCookieStateOnServer({ context, state }) | setCookieState({ state }) |
setCookieStateOnClient({ state }) | Use Server Action with setCookieState({ state }) |
deleteCookieOnServer({ context }) | deleteCookieState() |
toNextStep({ router, ... }) | Use getStepDestination() with useRouter().push() |
getRedirect() | redirectToStep() |
API Route with getCookieStateApiHandler | Not needed - use Server Actions |
Import from next/router | Import from next/navigation |