Skip to main content

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>
);
}

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} />;
}

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 RouterApp Router
Import from .../next-wizard/pages-routerImport 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 getCookieStateApiHandlerNot needed - use Server Actions
Import from next/routerImport from next/navigation