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.
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:
- A true Superadmin role: To manage tenants, users, and invitations across the entire system.
- 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.
- Seamless Tenant Switching: Users belonging to multiple tenants needed a way to switch contexts effortlessly.
- 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:
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 theisSuperAdminflag 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, theirtenantIdremainsundefined, 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, atrigger === "update"event fires, allowing us to update thetenantIdin 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:
// 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.
// 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:
- Production Deployment: The immediate priority is to deploy this to production. This involves a
git pull, rebuild,db:pushfor the newisSuperAdmincolumn, and crucially, manually runningUPDATE users SET "isSuperAdmin" = true WHERE email = 'oliver.baer@gmail.com';on the production database to elevate my admin account. - 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.
- RLS Policy Consideration: Currently,
isSuperAdminis enforced at the application layer. For stricter security, we'll consider adding Row-Level Security (RLS) policies to the database itself, leveraging this column. - 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.
{
"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"
]
}