Setting up a campaign in Nakodo is one decision per screen: who to look for, where to look, which businesses and places if you picked businesses, this campaign's share of the day's new emails if your plan has more than one campaign, and finally your website, which is the step that actually creates something. Then a review page.
The whole list is one function:
export function setupStepList(kind: CampaignKind | null, emails: boolean): Step[] {
return [
"kind",
"where",
...(kind === "businesses" ? (["businesses"] as const) : []),
...(emails ? (["emails"] as const) : []),
"website",
"review",
];
}
Four entries for most people, six for a business campaign on a plan with more than one campaign. The number of steps is a function of the plan and of a choice made two steps earlier, which is the detail that decides how everything else in this file has to work: you cannot hard-code "step 3 of 5" anywhere.
Two of those steps can also arrive pre-answered. On the free plan there is one kind of campaign to run, so kind is seeded from the only option, and every platform the plan allows starts selected:
const [kind, setKind] = useState<CampaignKind | null>(kinds.length === 1 ? kinds[0] : null);
const [chosen, setChosen] = useState<Record<CampaignKind, Platform[]>>(() => ({
creators: platforms.filter((p) => !p.lockedTo && KIND_PLATFORMS.creators.includes(p.platform)).map((p) => p.platform),
businesses: platforms.filter((p) => !p.lockedTo && KIND_PLATFORMS.businesses.includes(p.platform)).map((p) => p.platform),
}));
The screens still exist and still show what a bigger plan would add, with the locked options labelled by the cheapest plan that includes them. They just have nothing the user is required to answer, which turns out to matter at the end of this post.
The two rules I wanted were that the browser's Back button steps back through the wizard, and that nothing reaches the database until the end. Those pull in opposite directions, and the resolution is that the URL holds the step and only the step.
The step is a query parameter, moved with pushState
const go = (s: Step) => {
const url = new URL(window.location.href);
if (s === "kind") url.searchParams.delete("step");
else url.searchParams.set("step", s);
window.history.pushState(null, "", `${url.pathname}${url.search}`);
};
No router.push. The App Router integrates native pushState and replaceState with usePathname and useSearchParams, which the bundled docs spell out in 01-app/01-getting-started/04-linking-and-navigating.md:
pushStateandreplaceStatecalls integrate into the Next.js Router, allowing you to sync withusePathnameanduseSearchParams.
So the component reads params.get("step") and re-renders, with no server round trip, no RSC request and no risk of the server tree re-rendering mid-wizard and throwing away client state that nothing has saved yet. The first step deletes the parameter rather than setting step=kind, so the canonical URL of the wizard is /campaigns/new and the Back button from step two lands on exactly that.
A watermark, because a deep link into step four is a lie
Every piece of what the user has chosen is React state. A reload has nothing to restore it from. So the step in the URL is a request, not an instruction:
// The furthest step reached: later ones can't be opened from the URL or the
// step list until the ones before are done.
const [reached, setReached] = useState(() =>
site && kind && chosen[kind].length > 0 && !daily ? setupStepList(kind, false).indexOf("website") : 0,
);
// ...
const steps = setupStepList(kind, daily !== null);
const requested = steps.indexOf(params.get("step") as Step);
const at = requested > 0 && requested <= reached ? requested : 0;
requested is what the URL asked for, reached is the furthest the user has actually got, and at is the smaller of the two, falling back to the first step. A pasted ?step=website from someone else's screen opens step one. An unknown value gives indexOf of -1, which fails the > 0 test and also opens step one, so there is no separate validation branch.
Then the URL is tidied up, because a stale parameter that points nowhere is worse than no parameter:
// A reload loses the choices, so a step in the URL that can't be shown yet
// is dropped rather than left pointing somewhere else.
const stale = params.has("step") && at === 0;
useEffect(() => {
if (stale) window.history.replaceState(null, "", window.location.pathname);
}, [stale]);
replaceState rather than pushState: the bad URL should not become a history entry you can press Back into.
That is the honest cost of saving nothing: refresh the page three steps in and you start again. The alternative is a draft row per abandoned visit, or the same state in sessionStorage, which is a second copy of the truth that can disagree with the first. Two to four clicks of loss, against a table of half-built campaigns nobody will ever finish, and the clicks win. The step that does persist, the website, persists everything at once.
One expression says why Continue is off, and also locks the step list
Most wizards disable the button and let you guess. This one disables it and says so, and the string doubles as the gate:
// Why Continue is off, said next to it.
const missing =
step === "kind"
? !kind && "Choose who to look for"
: step === "where"
? platformsChosen.length === 0 && "Choose at least one place to look"
: step === "businesses"
? targeting.types.length === 0
? "Choose a kind of business"
: targeting.locations.length === 0 && "Add at least one place"
: step === "emails" && daily
? shares.total > daily.limit
? "Lower your other campaigns to fit your plan"
: own < 1 && "Give this campaign at least one new email a day"
: false;
A nested ternary chain is not pretty, and I keep it because the alternative shapes are worse. A Record<Step, () => string | false> spreads five sentences across five closures that each need the same six variables. A validation library gives you error codes you then have to map back to sentences in the user's words. This is one expression, read top to bottom in step order, in the same file as the UI it blocks, and the type of missing is string | false | undefined which the JSX treats as "a message or nothing".
The second use is the part I like:
<Stepper steps={steps} at={at} reached={missing ? at : reached} disabled={pending} onPick={go} />
While the current step is incomplete, the step list is told that the furthest reached step is the current one, so every later circle is disabled. One variable turns off the Continue button, prints the reason beside it, and closes the side door at the same time. Nothing can get out of sync, because there is nothing to sync.
const next = () => {
if (missing) return;
setReached((r) => Math.max(r, at + 1));
go(steps[at + 1]);
};
Math.max matters: go back two steps and forward again, and your watermark does not drop.
Each step announces itself
A wizard that swaps content without moving focus leaves a screen reader user at the bottom of the previous screen:
// Each new step starts at the top, with its heading read out.
const shown = useRef(step);
useEffect(() => {
if (shown.current === step) return;
shown.current = step;
window.scrollTo({ top: 0 });
heading.current?.focus({ preventScroll: true });
}, [step]);
The ref guard means the effect does nothing on mount, so arriving at the page does not steal focus, and preventScroll: true keeps the explicit scrollTo in charge rather than the browser's own scroll-into-view.
The step list itself is a nav with one button per step:
<nav aria-label="Campaign setup">
<ol className="flex items-center gap-2">
with aria-current="step" on the current one, disabled on the current and the unreached ones, a tick instead of a number for finished steps, and this for the labels:
<span className={next ? "sr-only sm:not-sr-only sm:whitespace-nowrap" : "sr-only"}>
{next && "Next: "}
{STEP_NAMES[s]}
</span>
A phone has room for exactly one label, so the only visible one is the next step, prefixed "Next: ", and from the sm breakpoint up it is visible while the rest stay screen-reader only with a title on hover. The heading below the list already names the current step, so printing it twice is noise for sighted users and the names are still there for everyone else.
The same component renders on the review page, which is a different route, with everything locked:
// The step list on the review page (new/[id]). The steps before are done and
// can't be opened again: the campaign exists now, and the review edits them.
export function SetupSteps({ steps, at }: { steps: Step[]; at: Step }) {
const i = steps.indexOf(at);
return <Stepper steps={steps} at={i} reached={i} disabled onPick={() => {}} />;
}
reached={i} with disabled means the past is visible and unclickable. Once the campaign exists, the way to change what you chose is to edit the campaign, not to walk the wizard backwards.
The typed domain survives four hops
On the home page there is one input and a button. It is a GET form with no JavaScript:
<form action="/login" className="flex max-w-md flex-col gap-3 sm:flex-row">
<input type="hidden" name="mode" value="signup" />
<Input id="hero-site" name="site" placeholder="yourbrand.com" inputMode="url" autoComplete="url" maxLength={253} />
What follows is four hops, each of which re-validates the domain with the same publicDomain() function rather than trusting the hop before it. The sign-in page keeps it in a hidden field and precomputes where sign-up should land:
const site = (typeof params.site === "string" && publicDomain(params.site)) || "";
const forSite = site ? `/campaigns/new?${new URLSearchParams({ step: "website", site })}` : "";
The sign-up action rebuilds it as a query on the post-confirmation route, and that route turns it into the wizard URL:
const site = publicDomain(request.nextUrl.searchParams.get("site") ?? "");
const next = site ? `/campaigns/new?${new URLSearchParams({ step: "website", site })}` : "/campaigns/new";
Which brings us back to the watermark, and the reason its initialiser has four conditions in it. A free-plan visitor who typed a domain has nothing to choose on the steps before the website, so the wizard opens on step four with the ones before it already ticked. On a paid plan with several campaigns there is a real decision in the way, so the same URL opens step one and the domain waits in state until the user gets there.
You can watch the first two hops from a terminal, no account needed:
$ curl -s "https://nakodo.app/login?mode=signup&site=example.com" | grep -o 'name="site" value="[^"]*"'
name="site" value="example.com"
$ curl -sI "https://nakodo.app/onboarding?site=example.com" | grep location
location: /login?next=%2Fonboarding%3Fsite%3Dexample.com
The second one is the auth boundary doing its job: /onboarding is private, so a stranger is bounced to sign-in with the whole original URL, domain included, in the next parameter.
The navigation happens inside the action
The last step submits to a Server Action that reads the website and creates the campaign, and that takes 10 to 40 seconds. The obvious shape is to await the action, then navigate, which gives you a flash of the wizard between "done" and "next page rendered". Instead the navigation is inside the action:
// On to the review once research has created the campaign. Opened inside
// this action, so "Reading" stays up until that page is ready rather than
// this flow showing again for a moment.
const [state, action, pending] = useActionState<ActionResult | null, FormData>(async (prev, form) => {
const r = await createCampaignAction(prev, form);
if (r.ok) router.push(r.next);
return r;
}, null);
pending stays true while the pushed route resolves, so the waiting screen covers the navigation as well as the work, and the user sees one continuous "Reading yourbrand.com. This usually takes 10 to 40 seconds." The same flag is passed to the step list as disabled, so nothing is clickable while it runs.
Four screens, one watermark, one string that explains itself, and not a single row written until the end of it. The product on the other side of the wizard is how Nakodo works, and which steps you get is decided by the plan limits on the pricing page: a plan with one campaign has no share-of-the-day step at all, and a plan with one kind of campaign has a first step with nothing to decide.
Top comments (0)