DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our welcome card prints a five digit permit number that cannot tell you how many users we have

Nakodo is dressed as a field guide. Plans are called Notebook, Field kit and Expedition on the pricing page, creators are catalogued with numbers, and contacted ones get a rubber stamp. So when someone finishes setting up their first campaign, what they get is a permit: a framed plate with their name, their plan, the date, and a stamp that lands on it a beat later.

Field permit No. 04182
Holder   you@example.com
Plan     Field kit, Pro
Issued   22 Oct 2026

Permit issued. Nakodo is out surveying now, and what it finds shows up below.
Enter fullscreen mode Exit fullscreen mode

It is 68 lines of component and it is the kind of thing that looks like pure decoration until you try to ship it. Three things in it are not decoration at all.

A sequential number would publish our user count

The obvious implementation of "No. 04182" is a counter. Do that and the first screen a new customer sees tells them exactly how many people signed up before them, which for an app in its first months is the one number you would rather not hand out. Worse, two customers comparing notes a week apart can measure the growth rate.

So the number is derived, not counted:

// A stable five-digit number from the user ID, so it doesn't reveal how many users there are.
function permitNumber(userId: string): string {
  return String(parseInt(userId.replace(/-/g, "").slice(0, 8), 16) % 100000).padStart(5, "0");
}
Enter fullscreen mode Exit fullscreen mode

The account's UUID is random, the first eight hex digits are 32 bits of it, and the modulo flattens that into 00000 to 99999. The same account always gets the same permit, with no column to store and no sequence to keep.

It collides, and that is fine. With a space of 100,000 and random inputs, the chance that two accounts share a number passes a half at a few hundred accounts, by the usual birthday arithmetic. Nothing looks this number up. Nothing joins on it. It appears once, in one card, for fifteen minutes. The thing to be deliberate about is not the collision rate; it is making sure nobody later decides that a number printed to users must obviously be unique and adds a column for it.

Two gates, and they answer different questions

The card is rendered from the signed-in layout:

{profile?.onboardedAt && justOnboarded(profile.onboardedAt) && (
  <Suspense>
    <FieldPermit permit={{ no: permitNumber(user.id), name: profile.fullName ?? user.email, plan: `${plan.kicker}, ${plan.name}`, issued: profile.onboardedAt.toISOString() }} />
  </Suspense>
)}
Enter fullscreen mode Exit fullscreen mode
const justOnboarded = (at: Date) => Date.now() - at.getTime() < 15 * 60 * 1000;
Enter fullscreen mode Exit fullscreen mode

and then the client component decides again:

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.",
};

const ending = ENDINGS[params.get("welcome") ?? ""];
if (!ending) return null;
Enter fullscreen mode Exit fullscreen mode

The server gate answers "is this account newly set up", from a column, so no amount of URL fiddling brings the card back next week. The client gate answers "is this the page view that setup redirected to", and it does it through a lookup rather than a boolean, because the sentence depends on how setup ended: the first campaign was launched, or a campaign Nakodo had already built for the customer's website was taken over while running.

The Record lookup is also the validation. ?welcome=, ?welcome=yes and a copied link with a stale value all read as "no ending", and the component returns null rather than rendering a card with a blank sentence in it.

The boolean that puts the parameter there exists once

Setup can be double submitted like anything else, so the fact that onboarding finished is written with a conditional update:

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);
Enter fullscreen mode Exit fullscreen mode

The isNull in the WHERE makes it idempotent, returning turns it into a compare and set, and the boolean that falls out is what the action appends to its redirect:

router.push(`/campaigns/${campaignId}${r.welcome ? "?welcome=launched" : ""}`);
Enter fullscreen mode Exit fullscreen mode

One request wins, one audit row is written, and only the winner gets the ceremony. The second click lands on the same campaign with no parameter and therefore no card, which is the correct outcome: you did not finish onboarding twice.

Dismissing it edits the URL rather than any state

const dismiss = () => {
  const next = new URLSearchParams(params);
  next.delete("welcome");
  router.replace(next.size ? `${path}?${next}` : path, { scroll: false });
};
Enter fullscreen mode Exit fullscreen mode

There is no dismissed state. The URL already is the state, so the close button removes the one parameter that makes the card render, copies the rest (the results table keeps its filters and sort in the query string, and losing them on a welcome card dismissal would be a real bug), uses replace so the back button does not bring the card back, and passes scroll: false so the page does not jump to the top as the card leaves.

The <Suspense> in the layout is not decoration either. useSearchParams in a client component makes everything above it dynamic unless it sits behind a boundary, and this layout wraps every signed-in page in the app.

The stamp is CSS, and it waits for the plate

The reward reads as paperwork being done in order: the plate fades in and slides down from above (motion-safe:animate-in fade-in-0 slide-in-from-top-2, 500ms), then the stamp hits it.

@keyframes stamp-land {
  from { opacity: 0; transform: scale(1.4) rotate(-6deg); }
  55% { opacity: 1; transform: scale(0.94) rotate(1deg); }
  75% { transform: scale(1.02) rotate(0deg); }
  to { transform: scale(1) rotate(0deg); }
}
@media (prefers-reduced-motion: no-preference) {
  .stamp-land {
    animation: stamp-land 0.45s cubic-bezier(0.3, 0, 0.3, 1) var(--stamp-delay, 0s) both;
  }
}
Enter fullscreen mode Exit fullscreen mode

The overshoot is the whole effect: 1.4 down past the resting size to 0.94, back up to 1.02, then 1. Real rubber stamps squash and rebound, and a stamp that eases politely into place looks like a sticker. The card passes [--stamp-delay:0.7s] as a Tailwind arbitrary property, so the delay lives at the call site while the animation stays in one keyframe block. The whole rule sits inside prefers-reduced-motion: no-preference, so a reader who has asked for less motion gets the stamp already landed, with no animation to opt out of.

Why bother

Because the end of setup is the one moment in a tool like this where nothing has happened yet. The campaign is launched, the first results are minutes away, and the screen would otherwise be an empty table. A card that says "permit issued" fills that gap with something that belongs to the product rather than a spinner, and it costs one derived number, two gates, one conditional update and twelve lines of CSS.

You can see the vocabulary it is built from without an account: the plan kickers on the pricing page, and the surveying metaphor it refers to on how it works. The permit itself only exists for fifteen minutes, once, which is the point.

Top comments (0)