The first-login experience is where most trial churn happens. A user who signs up, sees a blank dashboard with no obvious next step, and closes the tab is very unlikely to return. The onboarding flow is an engineering problem as much as a product one: getting the right data provisioned, the right role assigned, and the right email sequence triggered requires careful orchestration between your auth system and your application. Getting it wrong means lost customers who never had a real chance to evaluate your product.
Wizard vs immediate access
The two dominant onboarding patterns are the setup wizard and immediate access. Both have legitimate use cases.
The setup wizard gates access to the main application behind a linear flow: collect organization name, invite team members, configure essential settings. This is appropriate when the application is meaningless without initial configuration — an infrastructure monitoring tool that has no monitoring set up yet, or a CI/CD platform with no repositories connected. The wizard ensures the user reaches a meaningful state before they see the empty dashboard.
Immediate access drops the user directly into the application, surfacing onboarding prompts inline. This works for products where the core value can be demonstrated without configuration — a collaboration tool, a documentation platform, a reporting dashboard with sample data. The advantage is lower abandonment from users who just want to explore before committing to setup.
// Route middleware: determine if user needs onboarding
async function checkOnboardingState(req, res, next) {
const { userId, orgId } = req.user;
// Already completed onboarding
const org = await db.orgs.findById(orgId);
if (org.onboardingCompletedAt) {
return next();
}
// Check which steps are complete
const steps = await getOnboardingProgress(orgId, userId);
const incomplete = steps.filter(s => !s.completedAt);
if (incomplete.length === 0) {
// All steps done — mark org as onboarded
await db.orgs.update({ id: orgId }, { onboardingCompletedAt: new Date() });
await eventBus.publish('org.onboarding_completed', { orgId, userId });
return next();
}
// Redirect to the first incomplete step unless this is an onboarding route
if (!req.path.startsWith('/onboarding')) {
return res.redirect(`/onboarding/${incomplete[0].slug}`);
}
next();
}
Just-in-time provisioning
Just-in-time provisioning creates the user's workspace resources at the moment of first login, not before. This avoids maintaining stale provisioned resources for trials that never activate. The trigger is the completion of authentication — specifically, the event fired when a new user logs in for the first time.
// Event handler: first login for a new user
eventBus.subscribe('user.first_login', async (event) => {
const { userId, orgId, email } = event;
// Provision default resources in parallel
await Promise.all([
// Create the user's default workspace
createDefaultWorkspace(orgId, userId),
// Assign default role
db.orgMembers.update(
{ userId, orgId },
{ role: 'admin', roleAssignedAt: new Date() }
),
// Seed sample data if the org has no real data yet
seedSampleData(orgId),
// Start welcome email sequence
emailSequencer.enroll(userId, 'trial_onboarding', {
delay: 0, // Send first email immediately
orgId,
trialEndsAt: new Date(Date.now() + 14 * 24 * 3600 * 1000),
}),
// Notify product analytics
analytics.track(userId, 'Trial Started', {
orgId,
plan: 'trial',
source: event.source, // 'signup', 'invite', 'sso_jit'
}),
]);
});
Default role assignment
The first user who signs up for an organization is always the owner or admin. Every subsequent user who joins via invite gets the role specified in their invitation. Users who join via domain allow-list auto-join get a configurable default role (typically "member"). JIT-provisioned SSO users get a role mapped from their IdP attributes.
async function determineInitialRole(userId, orgId, joinMethod) {
const existingMemberCount = await db.orgMembers.count({ orgId });
if (existingMemberCount === 0) {
// First member — always owner
return 'owner';
}
switch (joinMethod) {
case 'invite':
// Role is specified in the invite record
const invite = await db.invites.findByUserAndOrg(userId, orgId);
return invite.role;
case 'domain_autojoin':
// Use the org's configured default role
const org = await db.orgs.findById(orgId);
return org.defaultMemberRole ?? 'member';
case 'sso_jit':
// Mapped from IdP attributes — handled separately
return await mapIdPRoleToLocalRole(userId, orgId);
default:
return 'member';
}
}
Welcome email trigger
The welcome email should fire exactly once — at the moment the user first activates their account. Duplicate welcome emails from signup and from first login are a common bug. The safest approach is to fire the welcome email from a single event source: the user.first_login event, not from the registration webhook, not from both.
The email sequence for a 14-day trial should be spaced to drive action without being annoying:
- Day 0 (immediately): welcome email with getting started guide and key actions
- Day 2 (if no key action taken): "Did you know..." with the one feature most users activate second
- Day 7 (if not activated): check-in with a link to book a demo call
- Day 12 (trial approaching end): trial expiry reminder with upgrade CTA
- Day 14 (trial expired): final email with downgrade impact and upgrade offer
Onboarding completion tracking
Track completion of each onboarding step as a separate event. This lets you see exactly where users drop off and iterate on the most impactful steps. An onboarding step should be marked complete when the user takes the meaningful action it was teaching — not just when they click "Next" past it.
// Mark an onboarding step as complete
async function completeOnboardingStep(orgId, userId, stepSlug) {
const step = await db.onboardingSteps.findOne({ orgId, slug: stepSlug });
if (!step || step.completedAt) return; // Idempotent
await db.onboardingSteps.update(
{ id: step.id },
{ completedAt: new Date(), completedBy: userId }
);
// Track in analytics
await analytics.track(userId, 'Onboarding Step Completed', {
step: stepSlug,
orgId,
timeToComplete: Date.now() - step.createdAt.getTime(),
});
// Check if all steps are now done
const remaining = await db.onboardingSteps.count({
orgId,
completedAt: null,
required: true,
});
if (remaining === 0) {
await eventBus.publish('org.onboarding_completed', { orgId, userId });
}
}