Trovella Wiki

Bootstrapping

How personal organizations are created on first sign-in and how the active organization is auto-selected.

When a user signs in for the first time, they have no organizations and no active organization on their session. Two functions handle this bootstrapping: ensurePersonalOrganization creates the user's personal workspace, and ensureActiveOrganization sets the session's active org so that tRPC requests work immediately.

Both functions are called from the DashboardPage server component on every authenticated page render. They are idempotent -- calling them repeatedly has no effect after the first run.

Where Bootstrapping Runs

// apps/web/src/components/dashboard/dashboard-page.tsx
export async function DashboardPage({ session, children }) {
  await ensurePersonalOrganization(
    session.user.id,
    session.user.name,
    session.user.email,
  );

  await ensureActiveOrganization(
    session.session.id,
    session.user.id,
    session.session.activeOrganizationId,
  );

  return (
    <Providers>
      <DashboardShell user={session.user}>{children}</DashboardShell>
    </Providers>
  );
}

This runs as a server component -- no client-side JavaScript is involved. The bootstrapping completes before the page renders, so by the time client components mount and start making tRPC calls, the organization context is guaranteed to be in place.

For details on the DashboardPage pattern and how authenticated pages are structured, see Application -- Routing & Pages.

ensurePersonalOrganization

Location: packages/auth/src/server.ts

Purpose: Guarantee that every user has at least one organization after first sign-in.

Flow:

  1. Look up the user's existing memberships using Better Auth's internal adapter
  2. If any membership exists, return early (idempotent guard)
  3. Generate a URL-safe slug from the user's name (falling back to email prefix)
  4. Create an organization with:
    • Name: "{displayName}'s Space" (e.g., "Kyle's Space")
    • Type: "personal" (stored in metadata)
    • Slug: lowercase, alphanumeric with hyphens, max 48 characters
  5. Create a member record linking the user to the organization with role "owner"

Key design decisions:

  • Uses Better Auth's adapter (auth.$context -> adapter) rather than Drizzle directly. This avoids session-context issues that arise when using the database inside Better Auth hooks.
  • The idempotent check is based on any membership existing, not on a personal org specifically. This means if a user is invited to another org before they ever visit the dashboard, the function skips personal org creation. In practice this does not happen because the invitation flow requires the invitee to sign in first.
  • The slug generation is simple (lowercase + hyphens) and does not check for collisions. Better Auth's adapter will throw if a duplicate slug exists, which is a case to handle when multi-user org creation is added.

ensureActiveOrganization

Location: packages/auth/src/server.ts

Purpose: If the session has no activeOrganizationId, set it to the user's first organization.

Flow:

  1. If activeOrganizationId is already set, return it immediately (idempotent guard)
  2. Look up the user's first membership
  3. If no memberships exist, return null
  4. Update the session record to set activeOrganizationId to that membership's organization
  5. Return the organization ID

Why this matters:

After Google OAuth creates the session, activeOrganizationId is null. Without this function, the first tRPC call would hit tenantProcedure and fail with PRECONDITION_FAILED ("No active organization selected"). Auto-selecting the first org makes single-org users (the common case in MVP) work seamlessly.

Sequence: First-Time Sign-In

Google OAuth callback
  -> Better Auth creates user, account, session (activeOrganizationId: null)
  -> Redirect to / (dashboard)

DashboardPage renders:
  1. ensurePersonalOrganization(userId, name, email)
     -> No existing memberships found
     -> Creates "Kyle's Space" org (type: personal)
     -> Creates member record (role: owner)

  2. ensureActiveOrganization(sessionId, userId, null)
     -> activeOrganizationId is null
     -> Finds first membership -> org ID
     -> Updates session.activeOrganizationId

  3. Dashboard renders, client components mount
     -> tRPC calls use session.activeOrganizationId
     -> tenantProcedure opens withTenantContext
     -> All queries are tenant-scoped

Sequence: Returning User

Browser sends request with session cookie
  -> Session already has activeOrganizationId set

DashboardPage renders:
  1. ensurePersonalOrganization(userId, name, email)
     -> Existing membership found -> returns early (no-op)

  2. ensureActiveOrganization(sessionId, userId, orgId)
     -> activeOrganizationId is already set -> returns it (no-op)

  3. Dashboard renders normally

Why Not Use Better Auth Hooks?

Better Auth supports databaseHooks that run on user creation. The bootstrapping was intentionally not placed there because:

  1. Adapter access issues -- hooks run in a context where using the same adapter for additional writes can cause transaction conflicts
  2. Testability -- explicit function calls from the server component are easier to reason about and test than implicit hooks
  3. Visibility -- the bootstrapping is visible in DashboardPage, making the dependency chain clear to developers reading the code

On this page