nyxcore-systems
7 min read

From Shared Spaces to Superadmin Control: Architecting Our Multi-Tenant Upgrade

We just shipped a monumental upgrade: a robust superadmin role, seamless multi-tenant onboarding, and intuitive tenant switching. No more new users accidentally seeing superadmin data! Here's a deep dive into how we built it.

Multi-tenancySuperadminAuthenticationNext.jstRPCPrismaSystem DesignDeveloper Experience

It was a problem many growing applications face: our initial setup, while great for getting off the ground, meant every new user automatically landed in a "default" tenant. This quickly became a security and privacy headache, especially when that "default" tenant was also where my superadmin account resided. The goal was clear: architect a proper multi-tenant system with a dedicated superadmin, controlled onboarding, and graceful tenant switching.

After a focused development sprint, I'm thrilled to report we've not only achieved this but pushed it to origin/main as 0fcc37b, complete with clean type checks and 180 passing tests. This post details the journey, the technical decisions, and a few valuable lessons learned along the way.

The Problem: Growing Pains of a Single-Tenant Default

Before this sprint, new user sign-ups were a bit too... communal. They'd automatically join the default tenant, which often contained sensitive superadmin data. This was a clear blocker for scaling and offering a secure, isolated experience for each organization. We needed:

  1. A true Superadmin role: To manage tenants, users, and invitations across the entire system.
  2. Controlled Onboarding: New users should either join a specific invited tenant or land in a "no tenant" state, prompting them to create or join one.
  3. Seamless Tenant Switching: Users belonging to multiple tenants needed a way to switch contexts effortlessly.
  4. Data Isolation: The bedrock of multi-tenancy.

Building the Foundations: Superadmin and Authentication

The first step was to introduce the concept of a superadmin into our data model.

1. The isSuperAdmin Flag

We added a simple boolean flag to our User model in prisma/schema.prisma:

prisma
model User {
  // ... other fields
  isSuperAdmin Boolean @default(false)
  // ... relations
}

This tiny addition was the cornerstone. After pushing the schema update, I manually set isSuperAdmin = true for my admin account directly in the database.

2. Rewiring JWT & Session Logic

The core of our authentication and user context lives in src/server/auth.ts, specifically the JWT and session callbacks. This was the most critical piece of the puzzle:

  • Loading isSuperAdmin: On login, we now explicitly load the isSuperAdmin flag from the database and embed it into the JWT and session object. This allows our frontend and backend to immediately recognize a superadmin.
  • Invitation Consumption: Before assigning a tenant, we added logic to automatically consume any pending invitations for the logged-in user's email via a new findAndConsumeForUser() service. This ensures users land directly in the tenant they were invited to.
  • Initial Tenant Assignment:
    • Superadmins: Only superadmins still auto-join the "default" tenant (for their initial setup).
    • Regular Users: Critically, regular users no longer auto-join default. Instead, if they have consumed an invitation, they join that tenant. If not, their tenantId remains undefined, pushing them into a "no workspace" state.
  • Tenant Switching: We leveraged session.update() within the JWT callback to handle dynamic tenant switching. When a user selects a new tenant from the UI, a trigger === "update" event fires, allowing us to update the tenantId in the session without a full re-login.

This comprehensive rewrite ensures that from the moment a user logs in, their tenant context and superadmin status are correctly established and maintained.

3. Securing Superadmin Routes with Middleware

With isSuperAdmin in the session, creating a security layer was straightforward. We added an enforceSuperAdmin middleware in src/server/trpc/middleware.ts:

typescript
// src/server/trpc/middleware.ts
export const enforceSuperAdmin = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user?.isSuperAdmin) {
    throw new TRPCError({ code: "UNAUTHORIZED", message: "Not a superadmin" });
  }
  return next({
    ctx: {
      // Infers the `session` as always existing
      session: ctx.session,
    },
  });
});

// src/server/trpc/routers/superadmin.ts (example usage)
export const superadminRouter = t.router({
  // ...
  listTenants: enforceSuperAdmin.query(({ ctx }) => {
    // ... logic to list all tenants
  }),
  // ...
});

This middleware now guards all procedures within our new src/server/trpc/routers/superadmin.ts router, ensuring only authorized users can access sensitive superadmin functionalities like listing tenants, creating new ones, or managing users across the entire system.

The User Experience: Onboarding and Switching

A robust backend needs a thoughtful frontend.

1. No Tenant? No Problem (or, a New Problem to Solve)

A key part of controlled onboarding meant that users without a tenantId in their session needed a specific experience. Our new NoTenantGuard in src/app/(dashboard)/layout.tsx now intercepts these users, presenting them with a "No workspace" page where they can either create a new tenant or accept an invitation.

We also updated the invite flow: after accepting an invitation, users are now redirected to /dashboard?switchTo=<tenantId>, which our NoTenantGuard picks up to automatically switch them into their new workspace.

2. The Tenant Switcher

For users belonging to multiple tenants (or superadmins who can see all tenants), a TenantSwitcher component was crucial. This dropdown, integrated into src/components/layout/header.tsx, allows users to:

  • View all their active memberships.
  • For superadmins, view all tenants in the system.
  • Seamlessly switch between contexts, triggering the session.update() mechanism mentioned earlier.

3. The Superadmin Dashboard

Finally, we built src/app/(dashboard)/dashboard/superadmin/page.tsx – a dedicated, tabbed interface accessible only to superadmins (via the new "Superadmin" nav item in the sidebar, sporting a crown icon!). This dashboard provides:

  • Tenants Tab: Create new tenants, view members of any tenant, invite users to any tenant.
  • Users Tab: List all users across the entire application.
  • Invitations Tab: Manage all outstanding invitations.

This centralized control panel empowers superadmins to manage the entire multi-tenant ecosystem efficiently.

Lessons from the Trenches

No development sprint is without its quirks. Here are a couple of "pain points" that turned into quick lessons:

1. Component Library Specifics: Badge Variants

While working on the superadmin dashboard, I tried to use a Badge component with variant="outline". Result? A quick TS2322 error.

typescript
// Attempted (and failed)
<Badge variant="outline">Superadmin</Badge> // TS2322: Type '"outline"' is not assignable to type '"default" | "accent" | "success" | "warning" | "danger"'.

Lesson Learned: Always double-check the available props and variants in your project's specific component library. Our internal Badge component only supports default, accent, success, warning, and danger. A quick switch to variant="default" resolved it. It's a small detail, but a good reminder about staying aligned with the established design system.

2. Local Dev Environment Gotchas: Docker State

Starting the session, my Docker containers for Postgres and Redis weren't running. This led to initial database connection errors during schema pushes.

Lesson Learned: Before diving into database-related tasks (like prisma db push or migrations), always run npm run docker:up (or your equivalent command) to ensure your local services are online and healthy. A quick check saves debugging time!

What's Next?

While the core functionality is shipped, there are always next steps:

  1. Production Deployment: The immediate priority is to deploy this to production. This involves a git pull, rebuild, db:push for the new isSuperAdmin column, and crucially, manually running UPDATE users SET "isSuperAdmin" = true WHERE email = 'oliver.baer@gmail.com'; on the production database to elevate my admin account.
  2. Comprehensive Testing: Thorough end-to-end testing of the full flow: creating a new tenant via superadmin, inviting a test user, verifying their isolation, and testing tenant switching for both regular users and superadmins.
  3. RLS Policy Consideration: Currently, isSuperAdmin is enforced at the application layer. For stricter security, we'll consider adding Row-Level Security (RLS) policies to the database itself, leveraging this column.
  4. E2E Test Suite: Building out a robust suite of E2E tests for all superadmin flows (tenant creation, invitation, switching) to prevent regressions.

Conclusion

This multi-tenant and superadmin upgrade marks a significant milestone for our application. We've moved from a shared, somewhat insecure default tenant model to a robust, isolated, and scalable architecture. The journey involved deep dives into authentication logic, careful API design, and thoughtful UI/UX considerations. It's a testament to how foundational changes, while complex, pave the way for future growth and a more secure, professional product.

json
{
  "thingsDone": [
    "Implemented `isSuperAdmin` flag in User model",
    "Rewrote JWT/Session callback for superadmin recognition, invitation consumption, and tenant assignment/switching",
    "Added `findAndConsumeForUser` service for invitations",
    "Created `enforceSuperAdmin` middleware for API security",
    "Developed `superadmin` tRPC router with tenant/user/invitation management",
    "Added `myTenants` query and `switchTenant` mutation for user-facing tenant management",
    "Built `TenantSwitcher` UI component",
    "Integrated `TenantSwitcher` into header",
    "Updated sidebar with conditional 'Superadmin' navigation",
    "Created `Superadmin` dashboard page with tabbed UI",
    "Implemented `NoTenantGuard` for users without an assigned tenant",
    "Updated invite acceptance redirect to include `switchTo` parameter"
  ],
  "pains": [
    "Misidentified Badge component variant (expected 'outline', only 'default' available)",
    "Docker containers (Postgres, Redis) not running at session start"
  ],
  "successes": [
    "Achieved full multi-tenancy with superadmin capabilities",
    "Seamless user onboarding and tenant switching",
    "Robust authentication and authorization for superadmin features",
    "Clean type checks and passing tests (180+)",
    "Significant improvement in application security and scalability"
  ],
  "techStack": [
    "Next.js",
    "tRPC",
    "Prisma",
    "PostgreSQL",
    "React",
    "TypeScript",
    "Docker",
    "JWT"
  ]
}