Stand up a multi-tenant request lifecycle
In a SaaS app every request needs the same four things resolved before your route code runs: who
is the user, what may they do, which tenant are they in, and is the database enforcing that
boundary. SMRT wires all four in a single hooks.server.ts — this guide builds it end
to end.
Prerequisites
- A SvelteKit app with SMRT configured (see Getting Started).
- A Postgres database — RLS is a Postgres feature. The session and tenant layers also work on SQLite, but the database-enforced layer needs Postgres.
- The users and tenancy packages installed.
pnpm add @happyvertical/smrt-users @happyvertical/smrt-tenancyStep 1 — Mark your models tenant-scoped
Each tenant-owned model needs @TenantScoped() and a @tenantId() column. Use mode: 'required' so operations fail closed without a tenant — this is also
the mode the RLS generator supports.
// src/lib/models/Project.ts
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';
@smrt({ api: true })
@TenantScoped({ mode: 'required' })
export class Project extends SmrtObject {
@tenantId()
tenantId: string = '';
name: string = '';
status: string = 'active';
}See Multi-tenancy for the full decorator reference.
Step 2 — The one hook
createSessionHandler() from @happyvertical/smrt-users/sveltekit is
the whole lifecycle. With enterTenantContext: true and postgresRls: true it loads the session, resolves permissions, enters tenancy context, and opens a request-scoped Postgres
transaction with the RLS session variables set — all before your route runs.
// src/hooks.server.ts
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';
export const handle = createSessionHandler({
db: { type: 'postgres', url: process.env.DATABASE_URL! },
enterTenantContext: true, // scope collection access to the current tenant
postgresRls: true, // open a request transaction + set RLS session vars
ttl: 7 * 24 * 60 * 60, // 7 days, in seconds
skipPaths: ['/api/health']
});After the hook runs, event.locals is populated for every request:
| Local | Type | Meaning |
|---|---|---|
user | User | null | The authenticated user, or null. |
permissions | string[] | Resolved permission slugs (resource.action). |
tenantId | string | null | The active tenant. |
sessionId | string | null | The server-side session id. |
Tell TypeScript about that shape by extending the SvelteKit Locals interface:
// src/app.d.ts
import type { SessionLocals } from '@happyvertical/smrt-users/sveltekit';
declare global {
namespace App {
interface Locals extends SessionLocals {}
}
}
export {};Step 3 — Use the context in routes
Now route code reads identity from locals and queries collections normally — the
tenancy interceptor scopes them to locals.tenantId automatically, so you never hand-write
a tenant filter.
// src/routes/projects/+page.server.ts
import { error } from '@sveltejs/kit';
import { ProjectCollection } from '$lib/models/ProjectCollection.js';
export async function load({ locals }) {
if (!locals.user) throw error(401, 'Sign in required');
if (!locals.permissions.includes('project.read')) throw error(403, 'Forbidden');
const projects = await ProjectCollection.create({
db: { type: 'postgres', url: process.env.DATABASE_URL! }
});
// Automatically filtered to locals.tenantId — no WHERE tenant_id needed.
return { projects: await projects.list({ where: { status: 'active' } }) };
}Step 4 — Login and logout
Establish the session cookie on login and clear it on logout. createSessionCookie() returns the new session id; both helpers set/clear the sid cookie the hook reads.
// src/routes/login/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { createSessionCookie } from '@happyvertical/smrt-users/sveltekit';
const db = { type: 'postgres' as const, url: process.env.DATABASE_URL! };
export const actions = {
default: async (event) => {
const data = await event.request.formData();
// ... verify credentials, look up the user and their tenant ...
const userId = '...';
const tenantId = '...';
await createSessionCookie(event, userId, tenantId, { db });
throw redirect(303, '/projects');
}
};// src/routes/logout/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { destroySessionCookie } from '@happyvertical/smrt-users/sveltekit';
const db = { type: 'postgres' as const, url: process.env.DATABASE_URL! };
export const actions = {
default: async (event) => {
await destroySessionCookie(event, { db });
throw redirect(303, '/login');
}
};Step 5 — Generate the RLS policies once
postgresRls: true sets the per-request session variables, but the database still needs
the policies that read them. Generate and apply them once (in a migration or a one-off script) from
your permission catalog.
// scripts/apply-rls.ts — run once after schema setup / catalog changes
import {
syncPermissionCatalog,
generatePostgresPermissionSql,
applyPostgresPermissionPolicies
} from '@happyvertical/smrt-users';
import './src/lib/models/index.js'; // register @smrt() classes
const db = { db: { type: 'postgres' as const, url: process.env.DATABASE_URL! } };
await syncPermissionCatalog(db);
// Inspect first: which tables get policies, and which were skipped (and why)
const preview = generatePostgresPermissionSql(db);
console.log('targets:', preview.targets);
console.log('skipped:', preview.skipped);
await applyPostgresPermissionPolicies(db);
console.log('RLS policies applied.');The generated policies check the session variables the hook sets each request — smrt.tenant_id, smrt.user_id, smrt.session_id, smrt.permissions, smrt.super_admin_bypass, smrt.system_context — and map CRUD to permission slugs (SELECT → read, INSERT → create, UPDATE → update, DELETE → delete).
The request, end to end
Incoming request
│
▼
hooks.server.ts (createSessionHandler)
├─ read 'sid' cookie → load Session
├─ resolve permissions (4-level cascade)
├─ enterTenantContext → smrt-tenancy scopes queries
└─ postgresRls → BEGIN; set smrt.tenant_id, smrt.permissions, …
│
▼
+page.server.ts / +server.ts
├─ read locals.user / locals.permissions
└─ collection.list() → interceptor adds WHERE tenant_id = …
→ RLS policy independently enforces the same boundary
│
▼
Response (transaction commits/rolls back)Related
- Concept: Multi-tenancy — the two isolation layers in depth.
- @happyvertical/smrt-users — sessions, permissions, OIDC, RLS generators.
- @happyvertical/smrt-tenancy — decorators and context runners.
Verified against SMRT v0.29.34.