⚠️ NOTE ⚠️: The
next-wizard
can be imported at the path'@krakentech/blueprint-onboarding/next-wizard'
.
Wizard code examples
Here are some examples of how we can use the the createNextWizard()
builder function to create a workflow of pages.
Code samples can be copied to clipboard or wrapped - hover over one to see the buttons.
API Route setup
The Next Wizard needs an API route to be able to persist the state in http only cookies on the server side. This can be easily setup with the getCookieStateApiHandler
helper.
// pages/api/onboarding/cookie-state.ts
import { getCookieStateApiHandler } from '@krakentech/blueprint-onboarding/next-wizard';
export default getCookieStateApiHandler({
encryptionSecret: process.env.ENCRYPTION_SECRET,
cookieOptions: {
// Can customise cookie options here
},
});
Create Wizard
The builder function createNextWizard
basically provides a set of strongly typed utils. They provide a very flexible and generic toolset to create features such as multiple page journeys.
⚠️ 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. Additionally, consider using the wizard.getInitialState
utility to obtain a structured clone of the default state. This approach ensures better encapsulation and aligns the state initialization directly with the wizard's configuration.
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,
cookieApiHandlerRoute: '/api/onboarding/cookie-state',
initialState: getInitialState(),
steps,
serializeState,
deserializeState,
});
Building a Page
In the getServerSideProps
function we use the wizard utils to fetch and validate the cookie state and do things such as redirect. Any validation logic is entirely opt-in and can be setup as needed. Please note, that getServerSideProps
requires the state to be serialized before being passed to the client.
export const getServerSideProps = async (
context: GetServerSidePropsContext
) => {
const cookieState = await wizard.getCookieStateOnServer(context);
try {
const isUnlocked = wizard.getIsStepUnlocked({
stepName: OnboardingStep.Tariffs,
state: cookieState,
submittedSteps: cookieState.submittedSteps,
});
if (!isUnlocked) {
throw new Error('Step verification failed');
}
return {
// We have to serialize the state before passing it to the client
props: { serializedState: wizard.serializeState(cookieState) },
};
} catch (error) {
// The getRedirect util returns a full NextJS
// redirect object to the last unlocked step
return wizard.getRedirect({
state: cookieState,
submittedSteps: cookieState.submittedSteps,
});
}
};
We then pass through the state to the page.
export default function Page({
serializedState,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <OnboardingStepXY serializedState={serializedState} />;
}
We now deserialize the state again and provide the form values to the formik state in the initial render. In the onSubmit function we then persist the values and get the target path for the next step.
export function OnboardingStepXY({
serializedState,
}: {
serializedState: OnboardingSerializedState;
}) {
const state = deserializeState(serializedState);
const router = useRouter();
return (
<Formik
initialValues={state.formValues}
onSubmit={async (values) => {
const newState: DemoOnboardingState = {
...state,
formValues: values,
submittedSteps: demoWizard.addToSubmittedSteps({
stepName: OnboardingStep.XY,
submittedSteps: state.submittedSteps,
}),
};
await wizard.setCookieStateOnClient({
state: newState,
});
await wizard.toNextStep({
currentStepName: OnboardingStep.XY,
state: newState,
router,
});
}}
>
<Form>...</Form>
</Formik>
);
}
Handling Query Parameters
For entry steps that need to process initial query parameters (like a postcode or referral code), the wizard provides a streamlined utility called handleQueryParams
. This utility will inject the parameters into your state, persist it to the cookie, and handle URL cleaning via redirect.
// Define an injection function that processes query parameters
const injectQueryParams: InjectQueryParamsFunc<OnboardingState> = ({
context,
state,
}) => {
const { postCode } = context.query;
// Check if the postCode is a string and matches the expected format
if (typeof postCode === 'string' && /^[0-9]{5}$/.test(postCode)) {
state.formValues[OnboardingKey.Postcode] = postCode;
}
};
export const getServerSideProps = async (
context: GetServerSidePropsContext
) => {
try {
const cookieState = await wizard.getCookieStateOnServer(context);
// Handle any query parameters and get a redirect if needed
const redirect = await wizard.handleQueryParams({
context,
injectQueryParamsFunc: injectQueryParams,
state: cookieState,
stepName: OnboardingStep.Quote,
});
// If there were query params to process, we'll redirect to clean the URL
if (redirect) {
return redirect;
}
// Continue with normal page rendering...
return {
props: { serializedState: wizard.serializeState(cookieState) },
};
} catch (error) {
// Handle errors...
return { notFound: true };
}
};
The handleQueryParams
utility handles several scenarios:
- If there are no query parameters, it returns null
- If there are query parameters, it:
- Calls your injection function to process them
- Saves the updated state to the cookie
- Returns a redirect to the same page, thereby removing any unwanted query parameters
- If the destination after processing would be the same as the current URL, it returns null
This approach eliminates the need to manually track whether query parameters have been processed.
Stepper
A stepper can easily be setup and configured using the generic utils. In contrast to the previous wizard, the Next Wizard is highly generic and doesn't have any dependencies on Coral.
const OnboardingPage = ({ children, state, stepName }: OnboardingPageProps) => {
const router = useRouter();
const stepperProps: StepperProps = useMemo(() => {
const currentStep = wizard.getStep({
state,
stepName,
});
const steps = wizard.getSteps(state);
// Find the index of the lowest step that has not been submitted.
const lowestUnsubmittedStepIndex = steps.findIndex(
(step) => !state.submittedSteps.includes(step.name)
);
// Total number of steps in the stepper.
const totalSteps: StepperProps['totalSteps'] = steps.length;
// Find the index of the current step in the stepper.
const activeStep: StepperProps['activeStep'] =
steps.findIndex((step) => step.name === currentStep.name) + 1;
// Generate the configuration for each step in the stepper.
const stepConfigs: StepperProps['stepConfigs'] = steps.map(
(step, i) => {
const title = {
[OnboardingStep.Quote]: 'Quote',
[OnboardingStep.Tariffs]: 'Tariffs',
[OnboardingStep.Details]: 'Details',
[OnboardingStep.Review]: 'Review',
}[step.name];
return {
title,
'aria-label': title,
/**
* Disable steps that have not been submitted yet.
* If all have been submitted then lowestUnsubmittedStepIndex will be -1.
*/
disabled:
lowestUnsubmittedStepIndex !== -1 &&
i > lowestUnsubmittedStepIndex,
};
}
);
const onStepClicked: StepperProps['onStepClicked'] = async (
stepNumber: number
) => {
const targetStep = steps[stepNumber - 1];
await router.push({
pathname: targetStep.pathname,
query: targetStep.query,
});
};
return {
totalSteps,
stepConfigs,
onStepClicked,
activeStep,
};
}, [router, state, stepName]);
return (
<Container
padding="md"
maxWidth="md"
component="section"
theme="mid"
marginX="auto"
marginBottom="lg"
>
<Stack direction="vertical" gap="sm">
<Stepper {...stepperProps} />
{children}
</Stack>
</Container>
);
};
export default OnboardingPage;