Multi-tenant SaaS products share a common pattern: users belong to organizations, each with a role that controls access. This post shows how to add that layer to a Next.js App Router project using Drizzle ORM -- three modules, one role hierarchy, and a token-based invite flow.
The org system splits across three modules:
modules/
organization/ -- org creation and lookup
orgMember/ -- membership and role management
invitation/ -- token-based invite flow
Each module follows the standard shape: schema -> relations -> types -> validation -> repo -> service -> index. Nothing leaks across boundaries -- routes call services, services call repos, repos own the DB queries.
Define the role enum once in orgMember.schema.ts and import it wherever else it is needed. Duplicate enum definitions cause a Drizzle migration conflict.
// modules/orgMember/orgMember.schema.ts
export const orgRoleEnum = pgEnum('org_role', ['owner', 'admin', 'member']);
export const orgMemberTable = pgTable('org_members', {
id: uuid('id').defaultRandom().primaryKey(),
orgId: uuid('org_id')
.notNull()
.references(() => organizationTable.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => userTable.id, { onDelete: 'cascade' }),
role: orgRoleEnum('role').notNull().default('member'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
The org slug is auto-generated server-side from the org name with a kebab-case transform. The client only sends name -- never the slug -- which keeps slug format consistent and prevents collisions.
// modules/organization/organization.service.ts
export async function createOrg(name: string, ownerId: string) {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const existing = await organizationRepo.findBySlug(slug);
if (existing) throw new HttpError(409, 'Slug already taken');
const org = await organizationRepo.create({ name, slug });
await orgMemberRepo.create({ orgId: org.id, userId: ownerId, role: 'owner' });
return org;
}
Enforce access in the service, not the route. This keeps routes thin and the access model in one place where it is easy to audit.
const ROLE_RANK: Record<OrgRole, number> = { owner: 3, admin: 2, member: 1 };
function requireRole(memberRole: OrgRole, required: OrgRole) {
if (ROLE_RANK[memberRole] < ROLE_RANK[required]) {
throw new HttpError(403, 'Insufficient permissions');
}
}
export async function deleteOrg(orgId: string, requesterId: string) {
const member = await orgMemberRepo.findByOrgAndUser(orgId, requesterId);
if (!member) throw new HttpError(403, 'Not a member');
requireRole(member.role, 'owner');
await organizationRepo.delete(orgId);
}
The route never sees the role check. It calls the service and handles the thrown HttpError via handleError().
Store the active org in localStorage as current_org_id. An OrgProvider reads it on mount and exposes setOrg and clearOrg via context. All authenticated API calls go through authFetcher, which appends both the JWT and the org header:
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${getToken()}`,
'X-Org-Id': getCurrentOrgId() ?? '',
...options.headers,
},
...options,
});
On sign-out, call clearOrg() alongside clearToken(). Leaving a stale current_org_id in storage will silently send every subsequent request to the wrong org.
Do not manage org state with plain useState in a page component -- it will not propagate across layout boundaries. Use the OrgProvider context at the root.
Invitations use a random UUID token, not a signed JWT. The route is public because token possession proves intent -- there is nothing else to verify before showing the invite details.
POST /api/invitations -- admin creates invite, service emails the link
GET /api/invitations/[token] -- public, returns org name and role
POST /api/invitations/[token]/accept -- creates member row, deletes invite
The accept step runs inside a transaction so a failure never leaves a dangling invite:
await db.transaction(async (tx) => {
await tx.insert(orgMemberTable).values({
orgId: invitation.orgId,
userId: user.id,
role: invitation.role,
});
await tx
.delete(invitationTable)
.where(eq(invitationTable.id, invitation.id));
});
Set a 7-day expiry on the invitation row and check it in the service before accepting. Return HttpError(410) for expired tokens so the client can show a meaningful message rather than a generic 400.
Add multi-tenancy in this order to avoid getting stuck:
npm run db:generate && npm run db:migrate after adding the three schemasOrgProviderX-Org-Id in route helpers alongside the JWT checkThe role check is a one-liner. The real work is deciding which operations require which role. Put that decision in the service layer, where it is visible and testable, and you will never have to hunt through middleware for an access bug again.