Toast
Contributor

Multi-Tenancy

How siteId scoping works, why it's non-negotiable, and how it's enforced structurally.

Toast is a multi-tenant platform. Every instance serves multiple independent sites from a single database. The entire data isolation model rests on one rule: every table has siteId, every query filters by it.

This is not a convention you remember to follow — it's structurally enforced.

How it works

Every database table has a site_id foreign key referencing the sites table:

export const content = pgTable(
  'content',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id),
    // ... domain columns
  },
  (table) => [index('idx_content_site_id').on(table.siteId)]
);

Every repository method receives siteId and filters by it — no exceptions:

export function createContentRepository(db: PostgresJsDatabase) {
  return {
    findBySiteId: (siteId: string) => db.select().from(content).where(eq(content.siteId, siteId)),

    findById: (siteId: string, id: string) =>
      db
        .select()
        .from(content)
        .where(and(eq(content.siteId, siteId), eq(content.id, id))),
  };
}

The siteId flows in from the session middleware, which extracts it from the authenticated user's session and stores it in Hono's context:

// Middleware sets it once
c.set('siteId', session.siteId);

// Controllers extract it once
const { siteId } = getAuthContext(c);

// Services receive it as a parameter
await contentService.createContent(input, siteId);

// Repositories use it in every query
.where(eq(content.siteId, siteId))

Why it's non-negotiable

A missing siteId filter is a data leak — Site A's content becomes visible to Site B. This is a security bug, not a style issue.

The reason Toast enforces this structurally rather than by convention:

  • td generate feature — the repository template has siteId scoping baked in. It is structurally impossible to generate a repository without it.
  • Code review — any repository method missing a siteId filter is a blocking review comment.
  • The type system — repositories are typed with siteId: string parameters. A method that doesn't accept siteId is a signal something is wrong.

siteId in the schema

Every table definition must follow this pattern:

export const myTable = pgTable(
  'my_table',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id), // required
    // ...
  },
  (table) => [index('idx_my_table_site_id').on(table.siteId)] // required
);

The index is not optional — queries without it will do full table scans on large datasets.

The one exception: global tables

A small number of tables exist outside the multi-tenancy model:

  • sites — the tenant registry itself
  • migrations — Drizzle migration tracking

These don't have siteId because they are the site-level or system-level primitives. Every other table does.

Cross-site operations

There are no legitimate cross-site data operations in the application layer. If you find yourself needing to query across sites, that's a signal the operation belongs in a system-level migration script or admin tool, not in a service method.

On this page