Multi-tenant onboarding: the first-login flow that converts trials to paying customers

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 });
  }
}
Track time-to-first-value, not time-to-signup-completion. The metric that correlates with paid conversion is how long it takes a user to achieve the core action your product exists for — the first build, the first report, the first integration. Your onboarding flow should be engineered to minimize that number, not to maximize wizard completion rates.
← Back to blog Try Bastionary free →