Dynamic onboarding: a different first step for every user
A teacher and a student sign up the same minute. The teacher wants to build a quiz; the student wants to summarize a lecture. Your onboarding shows them both the same five-step checklist. One of them bounces.
The static checklist is a guess that never updates
Most onboarding is a hardcoded array of steps: create a project, invite a teammate, connect a source, run your first job. It was written once, for an imagined median user, and it never adapts. The problem is that "median user" rarely exists. A teacher signing up to your study app and a student signing up the same minute have almost nothing in common except the button they clicked. Showing them the same five steps means at least one of them is reading instructions for a job they do not have - and that is the moment activation quietly dies.
You already know who they are
By the time someone finishes signup you usually have signal: a role they picked, the plan they landed on, a referral source, the first thing they clicked. If you run behavioral analytics you have even more - the events they fired in their first thirty seconds. The data to personalize the first step is sitting in your analytics profile. What you have been missing is a cheap way to turn "this person is a teacher who just imported a PDF" into a concrete, written next step without hand-coding a decision tree for every persona.
- user_attributes - the role, goal, or plan they declared at signup
- user_events - what they actually did in their first session
- popular_features - what users like them rely on most
- cohort_compare - where this user sits versus a comparable cohort
Compose turns the profile into a step
A Vevee compose type is a prompt plus the data sources it should read plus a structured output schema, all defined in the dashboard. You write the intent once - "Given this user’s role and what they have done so far, name the single most useful next step and one sentence of encouragement" - toggle on user_attributes and user_events, and define an output schema with a title, a body, and a CTA route. Your code never branches on persona. It asks compose for the next step and renders whatever comes back.
// app/onboarding/next-step/route.ts (server-side, secret key)
import { createClient } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
export async function GET(req: Request) {
const userId = await currentUserId(req);
const { output } = await vevee.compose<{
title: string;
body: string;
ctaLabel: string;
ctaHref: string;
}>('onboarding-next-step', userId);
return Response.json(output);
}Same type, two very different users
Because every source resolves against the userId you pass, one compose type produces a different step per person. The teacher who imported a PDF and the student who pasted lecture notes hit the exact same call and the exact same prompt - but the assembled context differs, so the output differs. No persona switch, no second code path. You change the experience by improving the prompt in the dashboard, not by shipping a release.
- Teacher (role=educator, event=imported_pdf) -> title: "Turn that PDF into a quiz", CTA: /quiz/new
- Student (role=student, event=pasted_notes) -> title: "Summarize your lecture in 3 bullets", CTA: /summarize
- Returning power user (high usage, many features) -> title: "Set up a shared workspace", CTA: /workspaces
Keep it off the hot path
Compose runs a real model call, so it is slower and costlier than a normal request - treat it that way. Generate the next step once, right after signup or on the first load of the onboarding screen, and cache the result on the user record. Re-generate only when their state meaningfully changes (they completed a step, they crossed a usage threshold), not on every render. The cost is metered against your workspace AI budget and returned on every call as usage.costMicroUsd, so you can watch exactly what onboarding personalization costs you per activated user.
Always have a fallback
Personalization should degrade gracefully. If compose throws ai_budget_exceeded because the budget is spent, or generation_failed on a transient model error, fall back to your old static first step rather than blocking the screen. The win is that the static checklist becomes the floor, not the ceiling: every user gets at least the generic path, and most get a step written for the job they actually showed up to do.
import { VeveeError } from '@vevee/sdk';
try {
const { output } = await vevee.compose('onboarding-next-step', userId);
return output;
} catch (e) {
if (e instanceof VeveeError) return STATIC_FIRST_STEP; // never block onboarding
throw e;
}Keep reading
I rewrote my cancel flow with one LLM call. It argues better than I do.
The cancel flow is where SaaS revenue goes to die politely. "Are you sure? You'll lose access to Premium features" has never changed a single mind - but reminding a user of their own 312 generations this month is an argument.
ComposeThe trial-ending email everyone sends is the same email. Here's the one that converts
"Your trial ends in 3 days! Upgrade now to keep access." You've received a hundred of these. You've deleted a hundred of these. The email fails because it's about your product, not about the user's trial.