SaaSClerkDrizzleNext.jsArchitectureTypeScript

Building Multi-Tenant SaaS with Clerk and Drizzle

Multi-tenancy is one of those problems that looks simple until you're debugging a data leak in production. Here's the architecture I use — three isolation layers, zero compromises.

L

Lazar Kapsarov

April 12, 2025 · 12 min read

Multi-tenant SaaS is one of those problems that sounds simple and isn't.

"Each customer gets their own account and sees only their own data." Easy to say. The implementation has at least a dozen places where you can get it wrong — and getting it wrong in production means one customer seeing another customer's data. That's not a bug report. That's a company-ending event.

I've built multi-tenant systems as portfolio centerpieces and as client work. This is the architecture I've landed on: Clerk for auth and tenant identity, Drizzle ORM for type-safe database access, and three explicit layers of tenant isolation that make data leaks structurally difficult rather than just hopefully avoided.

The Core Problem

In a single-tenant app, your data model is simple. Users have data. Done.

In a multi-tenant app, you have organizations (tenants), users who belong to organizations, roles within organizations, and data that belongs to organizations — not directly to users. The organizational boundary is the security boundary. Every database query, every API call, every UI action has to respect it.

There are three main isolation strategies:

Separate databases — Each tenant gets their own database instance. Maximum isolation, maximum cost, painful to operate at scale. Good for enterprise customers with compliance requirements.

Separate schemas — Each tenant gets their own schema within one database. Better than shared tables, but complex migrations and awkward with connection pooling.

Shared tables with tenant ID — All tenants share tables, every row has an organization_id column, every query filters by it. Lowest cost, easiest to operate, most common mistake vector if you're not disciplined.

I use shared tables with tenant ID, but with layered safeguards that make the "forgot the WHERE clause" failure mode nearly impossible.

The Stack

  • Next.js 15 with App Router and Server Components
  • Clerk for authentication and organization management
  • Drizzle ORM with Neon (serverless Postgres)
  • TypeScript throughout — types are part of the isolation strategy

Step 1: Clerk Organizations as Tenants

Clerk's Organizations feature maps directly to the multi-tenant concept. Each organization is a tenant. Users join organizations, get roles within them, and Clerk tracks all of this in their auth layer — so you don't have to.

In your Next.js middleware:

// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/api/(.*)"]);

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jte|ttf|woff2?|png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)).*)",
  ],
};

In any Server Component or API route, you get the current user and their active organization:

import { auth } from "@clerk/nextjs/server";

export default async function DashboardPage() {
  const { userId, orgId } = await auth();

  if (!userId || !orgId) redirect("/sign-in");

  // orgId is your tenant identifier
}

The orgId is your tenant key. It's cryptographically signed, comes from Clerk's session, and can't be spoofed by the user. This is your anchor for everything else.

Step 2: The Database Schema

Every tenant-scoped table gets an organizationId column. No exceptions.

// packages/database/src/schema.ts
import {
  pgTable,
  text,
  timestamp,
  uuid,
  varchar,
  integer,
} from "drizzle-orm/pg-core";

export const organizations = pgTable("organizations", {
  id: text("id").primaryKey(), // Clerk org ID (org_xxxxxxx)
  name: varchar("name", { length: 255 }).notNull(),
  plan: varchar("plan", { length: 50 }).default("free").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

export const projects = pgTable("projects", {
  id: uuid("id").defaultRandom().primaryKey(),
  organizationId: text("organization_id")
    .notNull()
    .references(() => organizations.id, { onDelete: "cascade" }),
  name: varchar("name", { length: 255 }).notNull(),
  status: varchar("status", { length: 50 }).default("active").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const teamMembers = pgTable("team_members", {
  id: uuid("id").defaultRandom().primaryKey(),
  organizationId: text("organization_id")
    .notNull()
    .references(() => organizations.id, { onDelete: "cascade" }),
  clerkUserId: text("clerk_user_id").notNull(),
  role: varchar("role", { length: 50 }).default("member").notNull(),
  joinedAt: timestamp("joined_at").defaultNow().notNull(),
});

Note: the organizations.id uses Clerk's org ID directly as the primary key. No mapping table. Clerk's orgId IS your database tenant ID. This eliminates an entire join and an entire class of bugs.

Step 3: The Tenant-Scoped Database Client

This is the isolation layer that makes me sleep at night.

Instead of passing organizationId as a parameter to every query function — which relies on every developer remembering to do it correctly — I create a scoped database client that bakes the tenant into every query automatically.

// packages/database/src/tenant.ts
import { db } from "./index";
import { projects, teamMembers } from "./schema";
import { eq, and } from "drizzle-orm";

export function createTenantDb(organizationId: string) {
  return {
    projects: {
      findMany: () =>
        db
          .select()
          .from(projects)
          .where(eq(projects.organizationId, organizationId)),

      findById: (id: string) =>
        db
          .select()
          .from(projects)
          .where(
            and(
              eq(projects.id, id),
              eq(projects.organizationId, organizationId),
            ),
          )
          .then((rows) => rows[0] ?? null),

      create: (data: { name: string; status?: string }) =>
        db
          .insert(projects)
          .values({ ...data, organizationId })
          .returning()
          .then((rows) => rows[0]),

      update: (id: string, data: Partial<{ name: string; status: string }>) =>
        db
          .update(projects)
          .set({ ...data, updatedAt: new Date() })
          .where(
            and(
              eq(projects.id, id),
              eq(projects.organizationId, organizationId),
            ),
          )
          .returning()
          .then((rows) => rows[0] ?? null),

      delete: (id: string) =>
        db
          .delete(projects)
          .where(
            and(
              eq(projects.id, id),
              eq(projects.organizationId, organizationId),
            ),
          ),
    },
    // Add more entities following the same pattern
  };
}

The tenant ID is captured once, in one place, at the top of the request. After that, it's impossible to accidentally query across tenants — the client doesn't expose that capability.

Step 4: Using It in Server Components

// app/dashboard/projects/page.tsx
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import { createTenantDb } from '@company/database/tenant'

export default async function ProjectsPage() {
  const { userId, orgId } = await auth()

  if (!userId || !orgId) redirect('/sign-in')

  // Tenant-scoped client — organizationId is locked in
  const tenantDb = createTenantDb(orgId)

  // This query CAN ONLY return projects belonging to orgId
  const projects = await tenantDb.projects.findMany()

  return <ProjectList projects={projects} />
}

There's no way to call tenantDb.projects.findMany() and get another tenant's projects. The isolation is structural, not conventional.

Step 5: The API Route Pattern

For API routes (mutations, webhooks, actions), the pattern is the same:

// app/api/projects/route.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { createTenantDb } from "@company/database/tenant";

export async function POST(request: Request) {
  const { userId, orgId } = await auth();

  if (!userId || !orgId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();
  const tenantDb = createTenantDb(orgId);

  const project = await tenantDb.projects.create({
    name: body.name,
  });

  return NextResponse.json(project, { status: 201 });
}

The orgId always comes from Clerk's session — never from the request body. A user cannot supply their own tenant ID.

Step 6: Row-Level Security as a Safety Net

Even with the application-layer isolation above, I add Postgres Row-Level Security as a database-level backstop. This catches bugs that make it past the application layer — runaway queries, ORM misuse, direct database access.

-- Enable RLS on all tenant-scoped tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy: app can only see rows matching the current org
CREATE POLICY tenant_isolation ON projects
  USING (organization_id = current_setting('app.current_org_id', true));

Set the org ID at the start of each database session:

// packages/database/src/rls.ts
import { sql } from "drizzle-orm";
import { db } from "./index";

export async function withTenantContext<T>(
  organizationId: string,
  fn: () => Promise<T>,
): Promise<T> {
  await db.execute(
    sql`SELECT set_config('app.current_org_id', ${organizationId}, true)`,
  );
  return fn();
}

This is belt-and-suspenders. The application layer handles the normal case. RLS handles the abnormal cases. Two independently enforced isolation mechanisms means one failure mode doesn't equal a data leak.

Handling Roles and Permissions

Clerk tracks organization membership and roles. You can check them without a database call:

import { auth } from "@clerk/nextjs/server";

const { orgRole } = await auth();
// 'org:admin' | 'org:member' | undefined

if (orgRole !== "org:admin") {
  return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

For more granular permissions, Clerk's custom roles let you define exactly what each role can do, checked at the session layer before the database is touched.

The Mistake I See Most Often

Developers building multi-tenant apps for the first time make the same mistake: they pass organizationId as a query parameter or request body field, then filter in the application layer.

// ❌ Don't do this
const orgId = request.headers.get("x-org-id"); // User-supplied
const projects = await db
  .select()
  .from(projects)
  .where(eq(projects.organizationId, orgId));

A malicious user can supply any orgId they want. If there's no server-side verification that the user actually belongs to that organization, they can read any tenant's data.

Always get the orgId from the verified session — from Clerk's auth() — never from the request.

What This Architecture Gets You

After building this properly, you get:

  • Structural isolation — tenant boundaries enforced by types and function signatures, not just developer discipline
  • Zero cross-tenant queries — the tenant-scoped client makes them impossible to express
  • Database-level safety net — RLS catches what the application layer misses
  • Type safety throughout — Drizzle's schema inference gives you TypeScript types from your database schema, so data shape errors are caught at compile time
  • Easy auditing — every query that touches tenant data goes through createTenantDb(). One function. One place to audit.

Multi-tenancy done this way isn't significantly more complex than a single-tenant app. It's just more disciplined. And in production, that discipline is the difference between a trusted product and a catastrophic data breach.


Lazar Kapsarov is a frontend engineer at PrismaFlux Media, specializing in scalable Next.js systems and SaaS architecture. If you're building a multi-tenant product and want to get the data model right from day one, book a free strategy call.

:: NEXT STEP

Have a frontend architecture problem?

Book a free 30-minute strategy call. I'll audit your current setup and show you exactly where you're losing time and money.

Book Free Strategy Call →