Blog / Engineering
EngineeringWhy we scoped every table by team_id from day one
Multi-tenancy is the kind of decision that's nearly free on day one and brutally expensive on day three hundred. Retrofitting team_id onto a schema that didn't plan for it means touching every query, every index, and every server action you've already shipped, usually under the pressure of the first customer who notices another tenant's data.
So Keel doesn't treat tenancy as a feature. Every table that holds tenant data carries a team_id from the first migration, every query is scoped through a single helper, and the can() permission check sits in front of every server action. This post walks through how that's wired, and why the boring version is the one that scales.
The schema rule
Any table that holds data belonging to a team gets a team_id column with a foreign key into teams, no exceptions. It's added in the same migration that creates the table, not bolted on afterward. A handful of genuinely global tables (auth sessions, feature flags scoped to the whole product) are the only ones exempt, and that exemption is explicit, not an oversight.
One scope helper, not one per query
The risk with team scoping isn't writing it once, it's writing it consistently. A query that forgets where team_id = ? doesn't fail loudly, it just leaks. So every scoped read or write goes through the same helper:
// db/scope.ts, every tenant query goes through here
export const scoped = (teamId: string) => ({
where: eq(table.teamId, teamId),
});
// usage in a server action, can() runs first, scope() runs always
export async function listProjects(user: User, teamId: string) {
if (!can(user, "projects:read", teamId)) throw forbidden();
return db.select().from(projects).where(scoped(teamId).where);
}
can() answers "is this user allowed to do this in this team." scoped() answers "which rows belong to this team." Neither one substitutes for the other, and a route that only checks the first without applying the second is the exact shape of bug that's easy to write and easy to miss in review.
Why this is worth doing before you have a second customer
The cost of this pattern is a few extra characters per query. The cost of skipping it is a migration, a security incident, or both, right when you can least afford either. Keel ships the helper, the permission check, and the convention written down in memory/architecture.md so it stays a habit instead of a one-time decision someone forgets six months in.
The Keel Team
We build Keel, the Next.js foundation you'd have built yourself. We write here about the decisions behind it.