Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 9 min read

We deleted 460 lines of onboarding, and /onboarding is now a 44 line redirect with no page

Nakodo had an onboarding flow. src/app/onboarding/ held page.tsx, forms.tsx, actions.ts, loading.tsx and error.tsx: 460 lines that asked a new account for their name, their company and their website, then sent them to cr

Nakodo had an onboarding flow. src/app/onboarding/ held page.tsx, forms.tsx, actions.ts, loading.tsx and error.tsx: 460 lines that asked a new account for their name, their company and their website, then sent them to create their first campaign, where the first question was their website.

Today's commit deletes the directory. In its place is a route handler with no UI at all, and the questions now get asked in the one place they were always going to be asked anyway. 52 files, 1,089 insertions, 1,284 deletions, and the app does more than it did.

The thing that made it obviously wrong

A campaign in Nakodo starts by reading the brand's website and writing a brief: what you sell, who it is for, which markets, which creators. The customer then checks that brief and launches. Setting up the first campaign is therefore already a guided, multi step, explanatory flow with a review at the end.

Onboarding was a second guided, multi step, explanatory flow, immediately before it, collecting a subset of the same facts. Two progress indicators, two sets of validation, two places where the website is typed, and one of them had its own bespoke illustration. That part stung: 183 lines of hand written SVG of a trail map went with it, and so did the 24 lines of CSS that animated it.

/* Onboarding's trail map: the trail just walked fills in from the left, then
   the new step's ring opens and the step left behind is ticked. */
@keyframes trail-walk {
  from { clip-path: inset(0 100% 0 0); }
  to { clip-path: inset(0 0 0 0); }
}

Deleting a feature means deleting its art, and the nicest thing in the diff is always the first casualty.

The question that killed it: what does "onboarded" actually mean for this product? It is not "answered three questions". It is "has a campaign out there working". Everything else was ceremony in front of that.

What /onboarding is now

A route.ts, so there is no page, no loading state and nothing to render:

// Where sign-up leads once the email is confirmed (src/app/(auth)/actions.ts).
// There are no questions here: the first campaign's setup (/campaigns/new) is
// onboarding, and it asks for the user's name where emails are signed with it.
// The product news tick from the sign-up form is recorded now the address is
// known to be theirs. A paid plan picked on the pricing page opens Stripe
// Checkout, which comes back to the setup whether the user pays or not; if
// Checkout can't open, they carry on with Free and can upgrade from Settings.
export async function GET(request: NextRequest) {
  const user = await requireUser();
  const profile = await getProfile(user.id);
  const started =
    profile?.onboardedAt ?? (await db.query.campaigns.findFirst({ where: eq(campaigns.userId, user.id), columns: { id: true } }));
  if (started) return NextResponse.redirect(new URL("/campaigns", request.url));

  const supabase = await createSupabase();
  const { data } = await supabase.auth.getClaims();
  const metadata = data?.claims?.user_metadata as { marketing?: unknown } | undefined;
  if (metadata?.marketing === true && (await optInAtSignUp(user.email, user.id))) {
    await audit({ action: "marketing.opted_in", userId: user.id, entityType: "email", entityId: user.email });
  }

  const next = "/campaigns/new";
  const plan = request.nextUrl.searchParams.get("plan");
  let target = next;
  if (isPaidPlan(plan)) {
    try {
      target = await checkoutUrl(user, plan, next);
    } catch (e) {
      console.error("Opening Checkout after sign-up failed", user.id, e);
    }
  }
  return NextResponse.redirect(new URL(target, request.url));
}

Four decisions, in order, and each one is there because of something that can go wrong.

started is two checks with a ??. The confirmation link in an email gets clicked twice, by the person and then by their mail client's link scanner. A route that only looked at onboardedAt would push an existing customer with four campaigns back into setup, because onboardedAt is set at the end rather than the beginning. So the fallback is "do they have any campaign at all", and the whole handler is idempotent.

The marketing opt-in is recorded here, not at sign-up. The tick on the sign-up form rides along in Supabase's user_metadata until this point, because at sign-up time nobody has proved they own that address. Writing a mailing list subscription for an address somebody typed is how you mail a stranger who never asked. Confirmation is the first moment the tick means anything, so that is where it becomes a row, and an audit entry.

Checkout is in a try. A customer who clicked "Choose Business" on the pricing page should see Stripe next, but if Stripe cannot open a session, the worst available outcome is a dead end on a brand new account. So the catch logs it and falls through to the setup flow on the free plan, from which Settings can upgrade. A payment provider having a bad afternoon cannot block sign-ups.

Checkout returns to /campaigns/new either way. Paid, cancelled, or abandoned: the return URL is the setup flow, so there is exactly one path forward and no state where somebody is signed in, unpaid, and staring at a page with no next step.

You can watch the first hop of that funnel from a terminal, because the pricing page encodes the plan in its own sign-up links:

$ curl -s https://nakodo.app/pricing | grep -o 'login?mode=signup[^"\\]*' | sort -u
login?mode=signup
login?mode=signup&plan=business
login?mode=signup&plan=pro

And the sign-up page with that parameter reads the plan back out to say what happens next. At the time of writing the deploy is still a commit behind, so that live page says "You chose Pro. After a couple of details you'll pay through Stripe, then add your website", which is precisely the promise this change deletes. The new sentence is "You'll pay through Stripe next, then set up your first campaign". When the deploy catches up, the proof of the change is one sentence on a page anyone can load.

The gate that went away

The old app had this in src/lib/auth.ts, and every app page called it:

// For app pages: also sends users who haven't finished onboarding there.
export const requireOnboardedUser = cache(async () => {
  const user = await requireUser();
  const profile = await getProfile(user.id);
  if (!profile?.onboardedAt) redirect("/onboarding");
  return { user, profile };
});

Eight lines, deleted. That function is an invariant enforced by convention: every current and future route under the app has to remember to call the onboarded variant rather than the plain one, or it becomes a hole. The reward for maintaining it was a redirect loop risk and a profile fetch on every single page.

With setup as onboarding, there is nothing to enforce. A new account lands in the campaign flow because that is where sign-up sends them, and a signed-in account with no campaigns sees the campaigns page with an empty state that invites them into the same flow. "Onboarded" stops being a gate and becomes a derived fact, recorded once, for a welcome message.

Finishing is one UPDATE

// Onboarding ends with the first campaign out surveying: launched, or taken
// over already running. True the first time, for the welcome on it.
async function finishOnboarding(userId: string, metadata: Record<string, unknown>): Promise<boolean> {
  const [row] = await db
    .update(profiles)
    .set({ onboardedAt: new Date() })
    .where(and(eq(profiles.id, userId), isNull(profiles.onboardedAt)))
    .returning({ id: profiles.id });
  if (row) await audit({ action: "onboarding.completed", userId, entityType: "user", entityId: userId, metadata });
  return Boolean(row);
}

isNull(profiles.onboardedAt) in the WHERE plus .returning() gives both properties in one round trip: the timestamp is never overwritten by a second launch, and the boolean says whether this launch was the first one. No read, no check, no race between two tabs both launching. The audit row is written only when the update actually changed something, so the audit log has exactly one completion per account.

That boolean is the only thing the welcome needs. The first launch redirects with ?welcome=launched, and a small client component turns it into a stamped field permit:

// How onboarding ended decides the line: the first campaign launched, or one
// made for the user's website taken over already running.
const ENDINGS: Record<string, string> = {
  launched: "Permit issued. Nakodo is out surveying now, and what it finds shows up below.",
  claimed: "Permit issued. Your campaign and its results are below.",
};

There are two endings because there are two ways to get a first campaign, and congratulating someone on launching a campaign they did not launch is worse than saying nothing. The layout only renders the component in the first minutes after onboardedAt, so a bookmarked ?welcome=launched URL does not bring the party back a month later, and dismissing it rewrites the query string with router.replace rather than reloading.

The steps are now computed

The setup flow's steps are data, shared between the client flow component and the server rendered final page:

export type Step = "kind" | "where" | "businesses" | "emails" | "website" | "review";

// Every step of setting up a campaign, in order. The last comes once research
// has created it, on a page of its own (new/[id]).
export function setupStepList(kind: CampaignKind | null, emails: boolean): Step[] {
  return [
    "kind",
    "where",
    ...(kind === "businesses" ? (["businesses"] as const) : []),
    ...(emails ? (["emails"] as const) : []),
    "website",
    "review",
  ];
}

Two of the six steps are conditional, so the progress indicator shows four, five or six steps depending on what you are actually doing, and it shows the same count on both sides of the research call that creates the campaign. The client component drives the flow and the server page renders the same list with at: "review" and every step before it ticked, which is only safe because both of them call this function instead of each holding a list.

The review page is its own route because it cannot exist before research has run:

// The proposal and details are written while the page is open (10 to 20
// seconds each), and research can be run again (10 to 40).
export const maxDuration = 120;

if (!brief || !inSetup(campaign)) redirect(campaign.launchedAt && campaign.status !== "draft" ? `/campaigns/${id}` : `/campaigns/${id}/brief`);

A bookmarked setup URL for a campaign that has since launched goes to the campaign, and one for a campaign that was abandoned mid setup goes to its brief. There is no state in which the setup URL shows a half built page.

And the one question onboarding used to ask that genuinely still needs an answer is now asked in context, conditionally:

askName: !profile?.fullName?.trim(),

Emails sent on a brand's behalf are signed with a person's name. So the name is asked for on the page where the first emails are about to be approved, to the people who will read them, and only if we do not already have it. Asked there, it is obviously necessary. Asked on a form before anything exists, it is just a form.

What I would take from this

The deletion was not a refactor. Nothing in the old flow was badly written: it had its own error boundary, its own skeleton, its own illustration and a perfectly good server action. That is why it survived as long as it did. The problem was that it existed, and the test that caught it was writing down what "onboarded" means in a sentence and then noticing that none of the 460 lines were about that sentence.

The public description of the flow those lines were in front of is on how it works, and the plan you can arrive through is on pricing. Neither page mentions onboarding, which in hindsight was the clue.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.