Setting up a campaign in Nakodo is a wizard, which is a word that usually comes with a drafts table, an autosave, a half-finished row somebody has to clean up, and a resume link. This one has none of that, and the reason is one line of policy: nothing exists in the database until the last step before the review.

What that buys is worth the trade I will come to at the end.

The number of steps is a function

export type Step = "kind" | "where" | "businesses" | "emails" | "website" | "review"; export function setupStepList(kind: CampaignKind | null, emails: boolean): Step[] { return [ "kind", "where", ...(kind === "businesses" ? (["businesses"] as const) : []), ...(emails ? (["emails"] as const) : []), "website", "review", ]; } 

Two conditional steps, so the wizard is four, five or six steps long. "Businesses and places" appears only for a campaign looking for businesses rather than creators. "Daily emails" appears only on a plan that can run more than one campaign at a time, because that step is about splitting a day's allowance between campaigns and there is nothing to split when there is one. On the Free plan it never appears at all.

The list lives in its own module rather than inside the component, and the file comment says why: the review page at the end is a server component on its own route, and it has to number the steps the same way. One function, two renderers, no chance of the stepper saying "Step 4 of 5" on one page and "4 of 6" on the next.

The step is in the URL, written by the History API

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 supports the native history methods and keeps useSearchParams in step with them, so a pushState updates the component that reads the step without touching the server. Moving between steps is therefore free: no request, no server render, no loading state, no revalidation of a page whose data has not changed. And because the step is a real history entry, the browser's Back button steps back through the wizard with no code of ours involved.

The first step deletes the parameter instead of setting step=kind, so the start of the wizard has one canonical URL rather than two.

The URL cannot name a step you have not reached

Putting the step in the URL means anyone can type one. The gate is three lines:

const [reached, setReached] = useState(/* ... */); const steps = setupStepList(kind, daily !== null); const requested = steps.indexOf(params.get("step") as Step); const at = requested > 0 && requested <= reached ? requested : 0; 

An unknown step gives indexOf of minus one, which fails the first test. A step further along than you have got fails the second. Either way you land on the first step, which is the only step that is always valid.

A client-side gate would be unacceptable if it were protecting anything. It is not: no step writes to the database, no step authorises anything, and the campaign does not exist yet. The gate is there so the screen is never asked to render a question that depends on an answer it does not have. The real validation is the server action at the end, which gets the whole set of answers in one form submission.

The reload case is handled explicitly, and it is the one place the design shows its seam:

// 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, not pushState, because a wiped parameter should not become a history entry you can press Back into.

The one URL that is allowed to skip ahead

The home page's hero is a single input. You can read it on the live page:

<input id="hero-site" name="site" placeholder="yourbrand.com" inputMode="url" autoComplete="url" maxLength="253" /> 

That address survives sign-up, through a route whose entire job is deciding where a new account lands, and arrives as /campaigns/new?step=website&site=yourbrand.com. The flow then seeds reached so the deep link is allowed:

const site = params.get("site")?.slice(0, 253) ?? ""; const [reached, setReached] = useState(() => site && kind && chosen[kind].length > 0 && !daily ? setupStepList(kind, false).indexOf("website") : 0, ); 

Read that condition as a sentence: if we were given a website, and the first two steps have answers that are correct by default on this plan, and there is no daily-emails step to ask about, then start at the website step with it filled in. A visitor who typed their address on a marketing page gets a one-field setup instead of a five-screen one, and the moment any of those conditions is false the whole wizard comes back.

The 253 is the maximum length of a domain name, and it appears twice: once as maxLength on the public input, once as a slice in the client that receives it. Neither is the real check, which is on the server, and both exist so the string in a URL bar cannot be a paragraph.

A disabled button that says why

Every step's Continue is disabled until that step is answered, and the reason is a value rather than a boolean:

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 string or false. One expression gives both "may we continue" and the sentence printed beside the button, so a greyed-out button is never a mystery, and the two can never disagree with each other because they are the same value. Five reasons for five ways to be stuck, including two different ones inside the businesses step.

The step's choices also hold a third state worth stealing. Each platform tile is offered, locked, or absent:

export type PlatformChoice = { platform: Platform; lockedTo: string | null }; 

Locked means it exists but your plan does not include it, so the tile renders with a lock and names the plan that has it. Absent means it is not configured on this deployment, so it never reaches the client at all. Those are genuinely different facts and a boolean would have flattened them into one sad tile.

The trade

A reload loses your answers. That is the whole cost, and it is a real cost: five screens of choices live in useState and nowhere else, so a refresh at step four puts you back at step one with the URL quietly cleaned up behind you.

What it buys is that the database has no concept of a half-made campaign. No status: 'draft', no nightly job deleting abandoned rows, no query anywhere that has to remember to exclude them, no half-set-up campaign appearing in a list or a count or a plan limit. Every campaign row in the product is one somebody finished and launched, and what happens after that is the part worth writing code for.

If I were to spend anything here it would be sessionStorage on the answers, keyed per tab, cleared on submit. That keeps the reload honest without teaching the database a new state, which is the part I would still refuse.