Skip to main content

Command Palette

Search for a command to run...

Building a Multi-Tenant Invoicing API with Drizzle, Next.js, and Better Auth

Updated
6 min read
Building a Multi-Tenant Invoicing API with Drizzle, Next.js, and Better Auth

We are building Reinvoice.co as a multi-tenant invoicing platform.

Each workspace has its own clients, invoices, tax settings, and team members. A freelancer might have one workspace. A small agency might have several. A user might even belong to multiple workspaces at the same time.

That creates one non-negotiable requirement:

A customer should never see another workspace’s invoice data.

Here is how we structured tenant isolation with Drizzle ORM, Next.js App Router, and Better Auth’s organization plugin.

Tenant Strategy: Shared Database, Row-Level Scoping

We use a shared Postgres database with row-level tenant scoping.

That means every tenant-owned table includes a workspace_id foreign key. We do not create a separate database or schema for each tenant.

export const invoices = pgTable('invoices', {
  id: uuid('id').defaultRandom().primaryKey(),

  workspaceId: uuid('workspace_id')
    .notNull()
    .references(() => workspaces.id, { onDelete: 'cascade' }),

  invoiceNumber: text('invoice_number').notNull(),

  totalAmount: numeric('total_amount', {
    precision: 12,
    scale: 2,
  }).notNull(),

  status: text('status', {
    enum: ['draft', 'sent', 'paid', 'overdue'],
  }).notNull(),

  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

The upside of this model is simplicity.

Migrations stay straightforward. Connection pooling stays efficient. Reporting across workspaces is possible when using carefully controlled aggregate queries.

The tradeoff is discipline: every tenant-owned query must be scoped by workspace_id.

We do not rely on remembering to add that filter manually each time. We centralize tenant access behind a small query layer.

The Tenant Context Pipeline

Every API request resolves the active workspace before touching tenant-owned data.

The request flow looks like this:

Request → Auth check → Workspace resolution → Membership check → Permission check → Handler

Better Auth’s organization plugin gives us the active organization for the session. In Reinvoice, that organization maps to a workspace.

export async function requireAppSession() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session?.user) {
    throw new UnauthorizedError();
  }

  if (!session.session.activeOrganizationId) {
    throw new MissingWorkspaceError();
  }

  return {
    userId: session.user.id,
    workspaceId: session.session.activeOrganizationId,
  };
}

The important part is that the active workspace comes from the authenticated server session, not from a client-controlled request body.

The client can request data. It cannot decide which tenant boundary applies.

Verifying Workspace Membership

A user can belong to more than one workspace.

For example, someone might have a solo freelance workspace and also belong to a shared LLC workspace. Switching workspaces is valid. Accessing a workspace where they are not a member is not.

Before serving workspace data, we verify membership server-side.

export async function requireWorkspaceMembership(
  userId: string,
  workspaceId: string,
) {
  const membership = await db.query.workspaceMembers.findFirst({
    where: and(
      eq(workspaceMembers.userId, userId),
      eq(workspaceMembers.workspaceId, workspaceId),
    ),
  });

  if (!membership) {
    throw new ForbiddenError();
  }

  return membership;
}

This protects against a common multi-tenant mistake: trusting a workspaceId passed from the browser.

Even if a user changes a request manually, the server still checks that the authenticated user belongs to that workspace.

The Tenant Query Layer

For tenant-owned tables, every query goes through a tenant-aware helper.

The goal is not to hide SQL. The goal is to make tenant scoping obvious, consistent, and testable.

export function invoiceWhere(
  workspaceId: string,
  condition?: SQL,
) {
  return condition
    ? and(eq(invoices.workspaceId, workspaceId), condition)
    : eq(invoices.workspaceId, workspaceId);
}

Then invoice queries use that helper instead of writing workspaceId filters inline.

export async function getInvoiceById(
  workspaceId: string,
  invoiceId: string,
) {
  return db.query.invoices.findFirst({
    where: invoiceWhere(
      workspaceId,
      eq(invoices.id, invoiceId),
    ),
  });
}

The same pattern applies to clients, payments, tax settings, and team-owned resources.

Could we write raw Drizzle queries everywhere? Yes.

But centralizing tenant filters gives us one place to test and review tenant isolation behavior.

Handling Cross-Workspace Users

Multi-workspace access is normal.

The key rule is that the user must actively switch workspaces, and every request must resolve against the currently active workspace.

That gives us predictable behavior:

User belongs to Workspace A and Workspace B
User selects Workspace A
All invoice queries are scoped to Workspace A
User switches to Workspace B
All invoice queries are scoped to Workspace B

We do not merge workspace data in normal product views.

If a user needs reporting across multiple workspaces later, that should be a separate feature with separate permission checks.

Migrations

Drizzle Kit generates SQL migration files.

In development, we use:

drizzle-kit push

In CI and production, we use:

drizzle-kit migrate

Most migrations are tenant-agnostic. Adding a column to invoices does not require special tenant logic because every row already carries workspace_id.

Backfills are different.

Any migration that updates existing tenant-owned data runs in workspace-scoped batches.

const workspacesToProcess = await db.query.workspaces.findMany({
  columns: { id: true },
});

for (const workspace of workspacesToProcess) {
  await backfillInvoicesForWorkspace(workspace.id);
}

That makes backfills easier to reason about and safer to retry.

What We Would Do Differently

Establish the tenant query layer earlier. Early on, some queries were written before the wrapper pattern was established. They worked, but they made tenant scoping harder to audit. The tenant query layer should have existed from the first tenant-owned table.

Add database-level enforcement sooner. Application-layer scoping is useful, but it should not be the only line of defense forever. For sensitive tenant data, Postgres Row Level Security can provide a stronger database-level backstop.

Rate limit by workspace, not only by user. A user with access to many workspaces can create more load than a single-workspace user. We are moving rate limits toward workspace_id as a key dimension.

Treat cross-workspace analytics as a privileged path. Aggregate analytics can be useful, but they should not reuse normal product queries casually. Cross-workspace reporting needs explicit access rules, careful aggregation, and no accidental exposure of tenant-level details.

The Takeaway

Multi-tenancy in Drizzle is straightforward because Drizzle stays close to SQL.

There are no hidden tenant filters. There is no framework magic. That is a good thing, but it also means tenant isolation has to be designed intentionally.

For Reinvoice, the model is:

Resolve the workspace from the session.
Verify membership server-side.
Scope every tenant-owned query by workspace_id.
Keep cross-workspace operations explicit.
Add database-level guardrails where the risk justifies it.

That is the discipline that keeps invoice data separated.