What We’re Building

This article builds a fully working multi-tenant SaaS dashboard from a blank SvelteKit project. Every file is shown in full. Every snippet runs without modification. No database service, no feature-flag service, no external dependencies required, we use an in-memory fake DB so you can run this immediately.

By the end you’ll have:

  • Subdomain-based tenant isolation (acme.localhost → Acme Corp, globex.localhost → Globex Inc)
  • Layered context architecture - default theme → tenant branding → admin dark mode, all using the same theme context key with shadowing
  • Feature flags per tenant loaded server-side from a fake DB (never computed client-side)
  • A protected admin route group with role checks that produce a proper 403, not a hidden button
  • A RequireFeature component for declarative feature-gated UI
  • All components implemented - AppHeader, AppSidebar, StatsCard, AdminNav - not just referenced

The stack: SvelteKit 2 + Svelte 5 Runes + TypeScript. No Prisma, no Lucia, no Unleash.

What we're building:
══════════════════════════════════════════════════════════════

  acme.localhost/dashboard          → Acme Corp dashboard (tenant branding)
  globex.localhost/dashboard        → Globex Inc dashboard (different branding)
  acme.localhost/admin/members      → Tenant admin panel (role-gated, dark theme)
  platform.localhost/admin/panel    → Superadmin: all tenants (platform-only)

  Context layers:
  ┌─────────────────────────────────────────────────────────┐
  │  +layout.svelte (root)                                  │
  │  └── setContext('theme', defaultTheme)  ← base          │
  │  └── setContext('appConfig', …)                         │
  │  └── setContext('session', …)                           │
  │       │                                                 │
  │       ▼ (app)/dashboard/+layout.svelte                  │
  │       └── setContext('tenant', …)                       │
  │       └── setContext('features', …)                     │
  │       └── setContext('theme', tenantTheme)  ← shadows   │
  │            │                                            │
  │            ▼ (admin)/admin/+layout.svelte               │
  │            └── setContext('theme', adminTheme) ← shadows│
  │            └── setContext('adminStats', …)              │
  │                                                         │
  │  Any deep component                                     │
  │  └── getContext('theme') → whichever is closest         │
  └─────────────────────────────────────────────────────────┘

Project Bootstrap

Start fresh:

npx sv create saas-context-demo
# Choose: SvelteKit minimal, TypeScript: Yes
cd saas-context-demo
npm install

Favicon & App Shell

<!-- src/app.html -->
<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<link rel="preconnect" href="https://fonts.googleapis.com" />
		<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
		<link
			href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
			rel="stylesheet"
		/>
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>

Drop any 32×32 PNG into static/favicon.png, or create a static/favicon.svg and change the href accordingly. Inter is loaded from Google Fonts, it gives the app a polished look without any local font setup.

Subdomain Dev Support

SvelteKit’s dev server listens on localhost. There are two ways to switch tenants locally, no /etc/hosts edits required for either:

Option 1 - Query param (works everywhere, zero setup)

http://localhost:5173?tenant=acme
http://localhost:5173?tenant=globex

Option 2 - Subdomain (works out of the box in modern browsers)

Modern browsers resolve *.localhost automatically. No DNS configuration, no /etc/hosts changes needed:

http://acme.localhost:5173/dashboard
http://globex.localhost:5173/dashboard

No vite.config.ts changes needed, the hook handles both approaches.

Final Project Structure

src/
├── app.d.ts                                     ← locals types
├── app.html                                     ← Inter font + favicon
├── hooks.server.ts                              ← tenant resolution, session check
├── lib/
│   ├── assets/
│   │   └── favicon.svg                          ← SVG favicon (optional)
│   ├── context-helpers.ts                       ← typed getContext wrappers
│   ├── server/
│   │   └── db.ts                                ← in-memory fake database
│   ├── types/
│   │   └── context.ts                           ← shared TS interfaces
│   └── components/
│       ├── TenantLogo.svelte                    ← maps TenantIconName → lucide icon
│       ├── AppHeader.svelte
│       ├── AppSidebar.svelte
│       ├── AdminNav.svelte
│       ├── StatsCard.svelte
│       └── RequireFeature.svelte
└── routes/
    ├── +layout.server.ts                        ← loads appConfig + session
    ├── +layout.svelte                           ← root context (theme, appConfig, session)
    ├── +page.server.ts                          ← redirect if authed, pass tenant
    ├── +page.svelte                             ← public landing (tenant-aware)
    ├── login/
    │   ├── +page.server.ts
    │   └── +page.svelte
    ├── logout/
    │   └── +page.server.ts                      ← clears session cookie
    ├── (app)/                                   ← user-facing route group
    │   └── dashboard/
    │       ├── +layout.server.ts                ← loads tenant + features
    │       ├── +layout.svelte                   ← tenant context (shadows theme)
    │       ├── +page.server.ts
    │       └── +page.svelte
    └── (admin)/                                 ← admin route group
        └── admin/
            ├── +layout.server.ts                ← role check → 403, isSuperAdmin branching
            ├── +layout.svelte                   ← admin context (shadows theme dark)
            ├── panel/                           ← superadmin only: all tenants
            │   ├── +page.server.ts
            │   └── +page.svelte
            └── members/                         ← tenant admins: own tenant members
                ├── +page.server.ts
                └── +page.svelte
static/
└── favicon.png

Step 1: Shared Types

// src/lib/types/context.ts

export interface AppConfig {
	name: string
	version: string
	environment: string
}

export interface Theme {
	mode: 'light' | 'dark'
	colors: {
		primary: string
		accent: string
		background: string
		surface: string
		border: string
		text: string
		textMuted: string
	}
}

export type TenantIconName = 'rocket' | 'zap' | 'building2' | 'shield'

export interface Tenant {
	id: string
	slug: string
	name: string
	branding: {
		logoIcon: TenantIconName // maps to a lucide-svelte icon via TenantLogo.svelte
		primaryColor: string // CSS color string
		accentColor: string
	}
	plan: 'free' | 'pro' | 'enterprise'
}

export interface Features {
	advancedReporting: boolean
	apiAccess: boolean
	customDomains: boolean
	prioritySupport: boolean
	whiteLabeling: boolean
}

export interface UserSession {
	userId: string
	name: string
	email: string
	role: 'member' | 'admin' | 'owner' | 'superadmin'
	tenantId: string
}

Step 1b: Context Helper Functions

As the number of context keys grows, repeating getContext<{ readonly current: Tenant }>('tenant') in every component gets noisy. Extract the calls into a small helpers file:

// src/lib/context-helpers.ts
import { getContext } from 'svelte'
import type { AppConfig, Features, Tenant, Theme, UserSession } from '$lib/types/context'

// Theme is set as a plain object with getter properties for reactive colors.
// The object reference itself is stable, getContext returns it directly.
export const getTheme = () => getContext<Theme>('theme')

// All other context values use the getter-object pattern because they derive
// from $props(). Call .current in the component and wrap with $derived so
// Svelte tracks the dependency correctly.
export const getTenantCtx = () => getContext<{ readonly current: Tenant }>('tenant')
export const getFeaturesCtx = () => getContext<{ readonly current: Features }>('features')
export const getSessionCtx = () => getContext<{ readonly current: UserSession | null }>('session')
export const getAppConfigCtx = () => getContext<{ readonly current: AppConfig }>('appConfig')

Components then import the helpers and unwrap with $derived:

<script lang="ts">
	import { getTheme, getTenantCtx, getFeaturesCtx } from '$lib/context-helpers'

	const theme = getTheme()
	const tenantCtx = getTenantCtx()
	const featuresCtx = getFeaturesCtx()

	const tenant = $derived(tenantCtx.current)
	const features = $derived(featuresCtx.current)
</script>

If you rename a context key or change a type, the TypeScript error surfaces immediately across every consumer, not as a runtime undefined that’s hard to trace. The components in this article call getContext directly for clarity, but in a real project you’d use these helpers throughout.


Step 2: The Fake In-Memory Database

Demo Only

This resets on every server restart and has no real password hashing. Use it to understand the patterns, swap in Drizzle + better-auth for production.

// src/lib/server/db.ts

import type { Features, Tenant, TenantIconName, UserSession } from '$lib/types/context'

// ─── Internal shapes ────────────────────────────────────────────────────────

interface DBTenant {
	id: string
	slug: string
	name: string
	plan: 'free' | 'pro' | 'enterprise'
	branding: {
		logoIcon: TenantIconName
		primaryColor: string
		accentColor: string
	}
}

interface DBUser {
	id: string
	tenantId: string
	name: string
	email: string
	/** ⚠️ Demo only — plaintext comparison */
	password: string
	role: 'member' | 'admin' | 'owner' | 'superadmin'
}

interface DBSession {
	token: string
	userId: string
	expiresAt: Date
}

interface DBFeatureFlag {
	tenantId: string | null // null = global default
	key: string
	enabled: boolean
}

// ─── Seed data ───────────────────────────────────────────────────────────────

const tenants: DBTenant[] = [
	// ── Platform (internal — not a customer tenant) ───────────────────────────
	{
		id: 'tenant_platform',
		slug: 'platform',
		name: 'SaaS Platform',
		plan: 'enterprise',
		branding: {
			logoIcon: 'shield',
			primaryColor: '#6366f1',
			accentColor: '#818cf8'
		}
	},
	// ── Customer tenants ──────────────────────────────────────────────────────
	{
		id: 'tenant_acme',
		slug: 'acme',
		name: 'Acme Corp',
		plan: 'enterprise',
		branding: {
			logoIcon: 'rocket',
			primaryColor: '#ff6452',
			accentColor: '#ff7961'
		}
	},
	{
		id: 'tenant_globex',
		slug: 'globex',
		name: 'Globex Inc',
		plan: 'pro',
		branding: {
			logoIcon: 'zap',
			primaryColor: '#0ec5e9',
			accentColor: '#33a6e0'
		}
	},
	{
		id: 'tenant_initech',
		slug: 'initech',
		name: 'Initech LLC',
		plan: 'free',
		branding: {
			logoIcon: 'building2',
			primaryColor: '#ff7ee1',
			accentColor: '#ff17bd'
		}
	}
]

const users: DBUser[] = [
	// ── Platform superadmin ───────────────────────────────────────────────────
	{
		id: 'user_superadmin',
		tenantId: 'tenant_platform',
		name: 'Platform Admin',
		email: 'admin@platform.com',
		password: 'password123',
		role: 'superadmin'
	},
	// ── Acme Corp (enterprise) ────────────────────────────────────────────────
	{
		id: 'user_alice',
		tenantId: 'tenant_acme',
		name: 'Alice Anderson',
		email: 'alice@acme.com',
		password: 'password123',
		role: 'owner'
	},
	{
		id: 'user_bob',
		tenantId: 'tenant_acme',
		name: 'Bob Baker',
		email: 'bob@acme.com',
		password: 'password123',
		role: 'member'
	},
	{
		id: 'user_charlie',
		tenantId: 'tenant_acme',
		name: 'Charlie Chen',
		email: 'charlie@acme.com',
		password: 'password123',
		role: 'member'
	},
	{
		id: 'user_diana',
		tenantId: 'tenant_acme',
		name: 'Diana Patel',
		email: 'diana@acme.com',
		password: 'password123',
		role: 'member'
	},
	{
		id: 'user_ethan',
		tenantId: 'tenant_acme',
		name: 'Ethan Moore',
		email: 'ethan@acme.com',
		password: 'password123',
		role: 'member'
	},
	{
		id: 'user_fiona',
		tenantId: 'tenant_acme',
		name: 'Fiona Liu',
		email: 'fiona@acme.com',
		password: 'password123',
		role: 'member'
	},
	// ── Globex Inc (pro) ──────────────────────────────────────────────────────
	{
		id: 'user_carol',
		tenantId: 'tenant_globex',
		name: 'Carol Owen',
		email: 'carol@globex.com',
		password: 'password123',
		role: 'owner'
	},
	{
		id: 'user_george',
		tenantId: 'tenant_globex',
		name: 'George Walsh',
		email: 'george@globex.com',
		password: 'password123',
		role: 'admin'
	},
	{
		id: 'user_hannah',
		tenantId: 'tenant_globex',
		name: 'Hannah Brooks',
		email: 'hannah@globex.com',
		password: 'password123',
		role: 'member'
	},
	{
		id: 'user_ivan',
		tenantId: 'tenant_globex',
		name: 'Ivan Reyes',
		email: 'ivan@globex.com',
		password: 'password123',
		role: 'member'
	},
	// ── Initech LLC (free) ────────────────────────────────────────────────────
	{
		id: 'user_dave',
		tenantId: 'tenant_initech',
		name: 'Dave Nguyen',
		email: 'dave@initech.com',
		password: 'password123',
		role: 'owner'
	},
	{
		id: 'user_judy',
		tenantId: 'tenant_initech',
		name: 'Judy Kim',
		email: 'judy@initech.com',
		password: 'password123',
		role: 'admin'
	},
	{
		id: 'user_kevin',
		tenantId: 'tenant_initech',
		name: 'Kevin Scott',
		email: 'kevin@initech.com',
		password: 'password123',
		role: 'member'
	},
	{
		id: 'user_laura',
		tenantId: 'tenant_initech',
		name: 'Laura Stone',
		email: 'laura@initech.com',
		password: 'password123',
		role: 'member'
	}
]

/** Feature flags: null tenantId = global default; specific tenantId = override. */
const featureFlags: DBFeatureFlag[] = [
	// Global defaults (all tenants)
	{ tenantId: null, key: 'advancedReporting', enabled: false },
	{ tenantId: null, key: 'apiAccess', enabled: false },
	{ tenantId: null, key: 'customDomains', enabled: false },
	{ tenantId: null, key: 'prioritySupport', enabled: false },
	{ tenantId: null, key: 'whiteLabeling', enabled: false },

	// Acme (enterprise) — override everything on
	{ tenantId: 'tenant_acme', key: 'advancedReporting', enabled: true },
	{ tenantId: 'tenant_acme', key: 'apiAccess', enabled: true },
	{ tenantId: 'tenant_acme', key: 'customDomains', enabled: true },
	{ tenantId: 'tenant_acme', key: 'prioritySupport', enabled: true },
	{ tenantId: 'tenant_acme', key: 'whiteLabeling', enabled: true },

	// Globex (pro) — some features on
	{ tenantId: 'tenant_globex', key: 'advancedReporting', enabled: true },
	{ tenantId: 'tenant_globex', key: 'apiAccess', enabled: true },
	{ tenantId: 'tenant_globex', key: 'prioritySupport', enabled: true }
]

// Sessions created at runtime
const sessions: DBSession[] = []

// ─── Public API ──────────────────────────────────────────────────────────────

import { randomBytes } from 'node:crypto'

function generateToken() {
	return randomBytes(32).toString('hex')
}

function resolveFeatureFlags(tenantId: string): Features {
	function resolve(key: string): boolean {
		const override = featureFlags.find((f) => f.tenantId === tenantId && f.key === key)
		if (override !== undefined) return override.enabled
		const global = featureFlags.find((f) => f.tenantId === null && f.key === key)
		return global?.enabled ?? false
	}

	return {
		advancedReporting: resolve('advancedReporting'),
		apiAccess: resolve('apiAccess'),
		customDomains: resolve('customDomains'),
		prioritySupport: resolve('prioritySupport'),
		whiteLabeling: resolve('whiteLabeling')
	}
}

export const db = {
	tenants: {
		findBySlug(slug: string): DBTenant | null {
			return tenants.find((t) => t.slug === slug) ?? null
		},
		/** All tenants including the internal platform tenant. */
		all(): DBTenant[] {
			return [...tenants]
		},
		/** Customer tenants only — excludes the internal platform tenant. */
		customer(): DBTenant[] {
			return tenants.filter((t) => t.id !== 'tenant_platform')
		}
	},

	users: {
		authenticate(email: string, password: string): DBUser | null {
			const user = users.find(
				(u) => u.email.toLowerCase() === email.toLowerCase() && u.password === password
			)
			return user ?? null
		},
		findById(id: string): DBUser | null {
			return users.find((u) => u.id === id) ?? null
		},
		/** All users belonging to a specific tenant. */
		forTenant(tenantId: string): DBUser[] {
			return users.filter((u) => u.tenantId === tenantId)
		},
		/** All customer users — excludes platform superadmins. */
		allCustomer(): DBUser[] {
			return users.filter((u) => u.tenantId !== 'tenant_platform')
		}
	},

	sessions: {
		create(userId: string, ttlDays = 1): DBSession {
			const session: DBSession = {
				token: generateToken(),
				userId,
				expiresAt: new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
			}
			sessions.push(session)
			return session
		},
		findByToken(token: string): DBSession | null {
			const s = sessions.find((s) => s.token === token)
			if (!s || s.expiresAt < new Date()) return null
			return s
		},
		deleteByToken(token: string): void {
			const i = sessions.findIndex((s) => s.token === token)
			if (i !== -1) sessions.splice(i, 1)
		}
	},

	features: {
		forTenant(tenantId: string): Features {
			return resolveFeatureFlags(tenantId)
		}
	}
}

/** Build a typed Tenant from a raw DB record. */
export function toPublicTenant(raw: ReturnType<typeof db.tenants.findBySlug>): Tenant {
	if (!raw) throw new Error('toPublicTenant: null input')
	return {
		id: raw.id,
		slug: raw.slug,
		name: raw.name,
		plan: raw.plan,
		branding: raw.branding
	}
}

/** Build a typed UserSession from a raw DB user. */
export function toSession(raw: ReturnType<typeof db.users.findById>): UserSession {
	if (!raw) throw new Error('toSession: null input')
	return {
		userId: raw.id,
		name: raw.name,
		email: raw.email,
		role: raw.role,
		tenantId: raw.tenantId
	}
}

Demo Accounts

EmailPasswordTenantRolePlanAdmin access
admin@platform.compassword123Platformsuperadmin/admin/panel (all tenants)
alice@acme.compassword123Acme Corpownerenterprise/admin/members
bob@acme.compassword123Acme Corpmemberenterprisenone
carol@globex.compassword123Globex Incownerpro/admin/members
george@globex.compassword123Globex Incadminpro/admin/members
dave@initech.compassword123Initech LLCownerfree/admin/members
judy@initech.compassword123Initech LLCadminfree/admin/members

The superadmin account belongs to the internal tenant_platform tenant and must be accessed via platform.localhost:5173. It is the only role that sees /admin/panel (the full cross-tenant view) — all other admin-capable roles land at /admin/members where they see only their own tenant’s data.


Step 3: Extend SvelteKit Locals

event.locals is SvelteKit’s per-request server-side scratchpad. Every hook, load function, and API route in the same request shares it. Before you can write event.locals.tenant, TypeScript needs to know the shape.

// src/app.d.ts
import type { Tenant, UserSession } from '$lib/types/context'

declare global {
	namespace App {
		interface Locals {
			/**
			 * The resolved tenant for this request.
			 * Set by hooks.server.ts based on subdomain (or ?tenant= in dev).
			 * null when no matching tenant is found.
			 */
			tenant: Tenant | null

			/**
			 * The authenticated user session, or null if unauthenticated.
			 * Set by hooks.server.ts after validating the session cookie.
			 */
			session: UserSession | null
		}
	}
}

export {}

How locals flows through a request

Browser request to acme.localhost/dashboard


hooks.server.ts
  ├── Extracts "acme" from host → db.tenants.findBySlug('acme')
  ├── Sets event.locals.tenant = { id, name, branding, plan, … }
  ├── Reads session cookie → validates → db.users.findById(…)
  └── Sets event.locals.session = { userId, name, role, … }


+layout.server.ts (root)
  └── Reads locals.tenant, locals.session
  └── Returns { appConfig, session, tenantSlug }


(app)/+layout.server.ts
  └── Reads locals.tenant → db.features.forTenant(tenant.id)
  └── Returns { tenant, features }


Root +layout.svelte → setContext('theme', defaultTheme)
(app)/+layout.svelte → setContext('tenant', …), setContext('features', …),
                        setContext('theme', tenantTheme)  ← shadows root
(admin)/+layout.svelte → setContext('theme', adminTheme)  ← shadows app


Any deep component → getContext('theme') gets the closest ancestor's value

Every step is typed. If you change Locals in app.d.ts, TypeScript surfaces every broken reference.


Step 4: Server Hook - Tenant Resolution

The hook runs before every load function. It’s the only place tenant resolution and session validation happen.

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit'
import { db, toPublicTenant, toSession } from '$lib/server/db'

export const handle: Handle = async ({ event, resolve }) => {
	const host = event.request.headers.get('host') ?? ''

	// ── 1. Resolve tenant from subdomain ───────────────────────────────────────
	//
	// Production:  acme.yourapp.com  → slug = 'acme'
	// Development: acme.localhost    → slug = 'acme'
	// Fallback:    localhost:5173    → read ?tenant= query param, or 'acme' as demo default

	const tenantSlug = resolveTenantSlug(host, event.url)

	const rawTenant = db.tenants.findBySlug(tenantSlug)

	if (!rawTenant) {
		// Unknown tenant - return plain 404 (no HTML layout needed)
		return new Response(`Tenant "${tenantSlug}" not found.`, { status: 404 })
	}

	event.locals.tenant = toPublicTenant(rawTenant)

	// ── 2. Validate session ───────────────────────────────────────────────────
	const sessionToken = event.cookies.get('session')

	if (sessionToken) {
		const dbSession = db.sessions.findByToken(sessionToken)

		if (dbSession) {
			const rawUser = db.users.findById(dbSession.userId)

			if (rawUser && rawUser.tenantId === rawTenant.id) {
				// User must belong to this tenant - no cross-tenant access
				event.locals.session = toSession(rawUser)
			} else {
				// Stale or cross-tenant session - clear it
				db.sessions.deleteByToken(sessionToken)
				event.cookies.delete('session', { path: '/' })
				event.locals.session = null
			}
		} else {
			// Expired or invalid token - clear the cookie
			event.cookies.delete('session', { path: '/' })
			event.locals.session = null
		}
	} else {
		event.locals.session = null
	}

	return resolve(event)
}

function resolveTenantSlug(host: string, url: URL): string {
	// Strip port number
	const hostname = host.split(':')[0]

	// Single-label host (plain 'localhost') - use query param or demo default
	if (!hostname.includes('.')) {
		return url.searchParams.get('tenant') ?? 'acme'
	}

	// Multi-label: first segment is the subdomain (acme.localhost, acme.yourapp.com)
	const subdomain = hostname.split('.')[0]

	// Treat 'www' and 'app' as marketing / auth subdomains - no tenant
	if (subdomain === 'www' || subdomain === 'app') {
		return url.searchParams.get('tenant') ?? 'acme'
	}

	return subdomain
}
Cross-Tenant Protection

Notice the rawUser.tenantId === rawTenant.id check. If Alice from Acme somehow has a valid session cookie but lands on globex.localhost, she gets logged out rather than accessing Globex data. Tenant isolation happens at the session validation layer, not just the UI.


Step 5: Root Layout Server Load

// src/routes/+layout.server.ts

import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'

// Routes that guests can access without logging in
const PUBLIC_PATHS = ['/', '/login']

export const load: LayoutServerLoad = async ({ locals, url }) => {
	const { tenant, session } = locals

	// Redirect to login for protected routes
	const isPublic = PUBLIC_PATHS.some((p) => url.pathname.startsWith(p))
	if (!session && !isPublic) {
		const returnUrl = encodeURIComponent(url.pathname + url.search)
		redirect(303, `/login?returnUrl=${returnUrl}`)
	}

	return {
		appConfig: {
			name: 'SaaS Demo',
			version: '1.0.0',
			environment: process.env.NODE_ENV ?? 'development'
		},
		// Pass minimal session data to the client
		session: session
			? { userId: session.userId, name: session.name, email: session.email, role: session.role }
			: null,
		// Pass tenant slug so the app layout can load the full tenant
		tenantSlug: tenant?.slug ?? null
	}
}

Step 6: Dashboard Layout Server Load

In the source project the layout that provides tenant context lives at src/routes/(app)/dashboard/+layout.server.ts rather than at the (app) group root. This means the tenant and feature data is scoped specifically to the dashboard subtree — other future routes inside (app)/ can opt in to different data shapes without inheriting this load automatically.

// src/routes/(app)/dashboard/+layout.server.ts

import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
import { db } from '$lib/server/db'

export const load: LayoutServerLoad = async ({ locals }) => {
	const { tenant, session } = locals

	if (!session || !tenant) {
		redirect(303, '/login')
	}

	// Load feature flags from DB — never compute entitlements client-side
	const features = db.features.forTenant(tenant.id)

	return {
		tenant, // Full Tenant object including branding
		features // Feature flags resolved for this tenant
	}
}

Step 7: Admin Route Group Server Load

The admin layout load function is the single authorisation gate for every route inside (admin)/admin/. It distinguishes between two admin tiers: superadmin, who belongs to the internal platform tenant and can see across all customer tenants, and admin/owner, who belong to a specific customer tenant and see only their own data.

// src/routes/(admin)/admin/+layout.server.ts

import { error, redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
import { db } from '$lib/server/db'

export const load: LayoutServerLoad = async ({ locals }) => {
	const { session, tenant } = locals

	if (!session) {
		redirect(303, '/login')
	}

	// Hard gate — members get 403, not a redirect
	if (!['admin', 'owner', 'superadmin'].includes(session.role)) {
		error(403, 'You need admin or owner role to access this area.')
	}

	const isSuperAdmin = session.role === 'superadmin'

	if (isSuperAdmin) {
		// Platform-level view: counts across all customer tenants
		return {
			adminStats: {
				totalTenants: db.tenants.customer().length,
				totalUsers: db.users.allCustomer().length,
				activeSessions: 1,
				tenantName: 'Platform',
				isSuperAdmin: true
			}
		}
	}

	// Tenant-scoped view: counts for this tenant only
	const memberCount = db.users.forTenant(tenant!.id).length

	return {
		adminStats: {
			totalTenants: 1,
			totalUsers: memberCount,
			activeSessions: 1,
			tenantName: tenant?.name ?? 'Unknown',
			isSuperAdmin: false
		}
	}
}

The isSuperAdmin flag flows into the admin layout component and from there as a prop into AdminNav, which uses it to conditionally show the tenant count stat and switch between the “All Tenants” link for superadmins and the “Members” link for tenant-level admins.


Step 8: Login Page

// src/routes/login/+page.server.ts

import { fail, redirect } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
import { db } from '$lib/server/db'

export const load: PageServerLoad = async ({ locals, url }) => {
	if (locals.session) {
		redirect(303, '/dashboard')
	}
	return {
		tenant: locals.tenant,
		returnUrl: url.searchParams.get('returnUrl')
	}
}

export const actions: Actions = {
	default: async ({ request, cookies, locals }) => {
		const form = await request.formData()
		const email = String(form.get('email') ?? '').trim()
		const password = String(form.get('password') ?? '')

		if (!email || !password) {
			return fail(400, { error: 'Email and password are required', email })
		}

		const dbUser = db.users.authenticate(email, password)

		if (!dbUser) {
			return fail(401, { error: 'Invalid email or password', email })
		}

		// Ensure user belongs to the current tenant
		if (locals.tenant && dbUser.tenantId !== locals.tenant.id) {
			return fail(401, { error: 'Invalid email or password', email })
		}

		const session = db.sessions.create(dbUser.id, 1)

		cookies.set('session', session.token, {
			path: '/',
			httpOnly: true,
			secure: process.env.NODE_ENV === 'production',
			sameSite: 'lax',
			maxAge: 60 * 60 * 24
		})

		const returnUrl = String(form.get('returnUrl') ?? '/dashboard')
		redirect(303, returnUrl.startsWith('/') ? returnUrl : '/dashboard')
	}
}
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
	import { enhance } from '$app/forms'
	import TenantLogo from '$lib/components/TenantLogo.svelte'
	import { Building2, Rocket, Zap } from 'lucide-svelte'
	import type { Tenant } from '$lib/types/context'

	interface Props {
		data: { tenant: Tenant | null; returnUrl: string | null }
		form?: { error?: string; email?: string }
	}

	let { data, form }: Props = $props()
	let isSubmitting = $state(false)

	const primaryColor = $derived(data.tenant?.branding.primaryColor ?? '#6366f1')
</script>

<svelte:head>
	<title>Sign In — {data.tenant?.name ?? 'SaaS Demo'}</title>
</svelte:head>

<div class="login-page">
	<div class="login-card">
		<div class="card-header">
			<div class="brand">
				<span class="logo-icon">
					<TenantLogo icon={data.tenant?.branding.logoIcon ?? 'rocket'} size={24} />
				</span>
				<span class="brand-name">{data.tenant?.name ?? 'SaaS Demo'}</span>
			</div>
			<h1>Sign in to your account</h1>
			<p class="subtitle">Enter your email and password below</p>
		</div>

		{#if form?.error}
			<div class="error-alert" role="alert">
				<!-- inline error icon -->
				<svg
					xmlns="http://www.w3.org/2000/svg"
					width="16"
					height="16"
					viewBox="0 0 24 24"
					fill="none"
					stroke="currentColor"
					stroke-width="2"
				>
					<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" />
					<line x1="12" y1="16" x2="12.01" y2="16" />
				</svg>
				{form.error}
			</div>
		{/if}

		<form
			method="POST"
			use:enhance={() => {
				isSubmitting = true
				return async ({ update }) => {
					await update()
					isSubmitting = false
				}
			}}
		>
			<input type="hidden" name="returnUrl" value={data.returnUrl ?? ''} />

			<div class="field">
				<label for="email">Email</label>
				<input
					type="email"
					id="email"
					name="email"
					value={form?.email ?? ''}
					placeholder="you@company.com"
					autocomplete="email"
					required
					disabled={isSubmitting}
				/>
			</div>

			<div class="field">
				<label for="password">Password</label>
				<input
					type="password"
					id="password"
					name="password"
					placeholder="••••••••"
					autocomplete="current-password"
					required
					disabled={isSubmitting}
				/>
			</div>

			<button
				type="submit"
				class="submit-btn"
				style:background={primaryColor}
				disabled={isSubmitting}
			>
				{#if isSubmitting}
					<!-- Spinner -->
					<svg
						xmlns="http://www.w3.org/2000/svg"
						width="16"
						height="16"
						viewBox="0 0 24 24"
						fill="none"
						stroke="currentColor"
						stroke-width="2"
						class="spin"
					>
						<path d="M21 12a9 9 0 1 1-6.219-8.56" />
					</svg>
					Signing in…
				{:else}
					Sign in
				{/if}
			</button>
		</form>

		<!-- Demo hint with per-tenant icons -->
		<div class="demo-hint">
			<p class="hint-label">Demo accounts — password: <code>password123</code></p>
			<div class="hint-accounts">
				<div class="hint-row">
					<code>alice@acme.com</code>
					<span class="hint-badge admin">admin</span>
					<span class="hint-tenant"><Rocket size={11} /> acme.localhost</span>
				</div>
				<div class="hint-row">
					<code>bob@acme.com</code>
					<span class="hint-badge member">member</span>
					<span class="hint-tenant"><Rocket size={11} /> acme.localhost</span>
				</div>
				<div class="hint-row">
					<code>carol@globex.com</code>
					<span class="hint-badge owner">owner</span>
					<span class="hint-tenant"><Zap size={11} /> globex.localhost</span>
				</div>
				<div class="hint-row">
					<code>dave@initech.com</code>
					<span class="hint-badge member">member</span>
					<span class="hint-tenant"><Building2 size={11} /> initech.localhost</span>
				</div>
			</div>
		</div>
	</div>
</div>

<style>
	.login-page {
		display: flex;
		justify-content: center;
		align-items: center;
		min-height: 100vh;
		background: var(--muted);
		padding: 2rem;
	}

	.login-card {
		background: var(--card);
		border: 1px solid var(--border);
		border-radius: var(--radius-lg);
		padding: 2rem;
		width: 100%;
		max-width: 400px;
		box-shadow: var(--shadow-md);
		display: flex;
		flex-direction: column;
		gap: 1.5rem;
	}

	.card-header {
		display: flex;
		flex-direction: column;
		gap: 0.375rem;
	}

	.brand {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		margin-bottom: 0.75rem;
	}

	.logo-icon {
		display: flex;
		align-items: center;
		color: var(--foreground);
	}

	.brand-name {
		font-size: 0.9375rem;
		font-weight: 700;
		color: var(--foreground);
		letter-spacing: -0.01em;
	}

	.card-header h1 {
		font-size: 1.375rem;
		font-weight: 700;
		color: var(--foreground);
		letter-spacing: -0.03em;
	}

	.subtitle {
		font-size: 0.875rem;
		color: var(--muted-foreground);
	}

	.error-alert {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		background: #fef2f2;
		border: 1px solid #fecaca;
		color: #dc2626;
		padding: 0.75rem 1rem;
		border-radius: var(--radius);
		font-size: 0.875rem;
	}

	form {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.field {
		display: flex;
		flex-direction: column;
		gap: 0.375rem;
	}

	label {
		font-size: 0.875rem;
		font-weight: 500;
		color: var(--foreground);
	}

	input[type='email'],
	input[type='password'] {
		padding: 0.5625rem 0.875rem;
		border: 1px solid var(--input);
		border-radius: var(--radius-sm);
		font-size: 0.9375rem;
		width: 100%;
		background: var(--background);
		color: var(--foreground);
		font-family: inherit;
		transition:
			border-color 0.15s,
			box-shadow 0.15s;
	}

	input:focus {
		outline: none;
		border-color: var(--foreground);
		box-shadow: 0 0 0 3px rgb(9 9 11 / 0.08);
	}

	input:disabled {
		opacity: 0.6;
		cursor: not-allowed;
		background: var(--muted);
	}

	.submit-btn {
		display: flex;
		align-items: center;
		justify-content: center;
		gap: 0.5rem;
		padding: 0.625rem;
		color: white;
		border: none;
		border-radius: var(--radius-sm);
		font-size: 0.9375rem;
		font-weight: 600;
		cursor: pointer;
		width: 100%;
		margin-top: 0.25rem;
		font-family: inherit;
		transition: opacity 0.15s;
	}

	.submit-btn:hover:not(:disabled) {
		opacity: 0.9;
	}
	.submit-btn:disabled {
		opacity: 0.65;
		cursor: not-allowed;
	}

	@keyframes spin {
		to {
			transform: rotate(360deg);
		}
	}
	.spin {
		animation: spin 0.8s linear infinite;
	}

	/* Demo hint */
	.demo-hint {
		padding-top: 1.25rem;
		border-top: 1px solid var(--border);
	}

	.hint-label {
		font-size: 0.8125rem;
		color: var(--muted-foreground);
		margin-bottom: 0.625rem;
	}

	.hint-accounts {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}

	.hint-row {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		font-size: 0.75rem;
		padding: 0.25rem 0;
	}

	.hint-badge {
		font-size: 0.625rem;
		font-weight: 600;
		padding: 0.125rem 0.4rem;
		border-radius: 999px;
		text-transform: uppercase;
		letter-spacing: 0.04em;
		color: white;
	}

	.hint-badge.admin {
		background: #6366f1;
	}
	.hint-badge.owner {
		background: #0ea5e9;
	}
	.hint-badge.member {
		background: #71717a;
	}

	.hint-tenant {
		display: flex;
		align-items: center;
		gap: 0.25rem;
		margin-left: auto;
		color: var(--muted-foreground);
		font-size: 0.6875rem;
	}
</style>

Step 9: Root Layout - Base Context

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { setContext } from 'svelte'
	import type { AppConfig, Theme } from '$lib/types/context'

	interface Props {
		data: {
			appConfig: AppConfig
			session: { userId: string; name: string; email: string; role: string } | null
		}
		children: import('svelte').Snippet
	}

	let { data, children }: Props = $props()

	// Getter wrappers for the same reason as tenant/features in (app)/+layout.svelte:
	// data is reactive ($props). Reading data.appConfig or data.session directly
	// at the top-level script body captures only the initial value. A getter
	// defers the read so consumers always get the live value via .current.
	setContext<{ readonly current: AppConfig }>('appConfig', {
		get current() {
			return data.appConfig
		}
	})
	setContext<{ readonly current: typeof data.session }>('session', {
		get current() {
			return data.session
		}
	})

	// Base theme - will be shadowed by tenant and admin layouts.
	// Colors match the CSS custom properties defined in :global(:root) below
	// so inline styles and CSS vars stay in sync.
	const defaultTheme: Theme = {
		mode: 'light',
		colors: {
			primary: '#ff6452', // matches --primary fallback
			accent: '#FF1C01',
			background: '#ffffff',
			surface: '#f4f4f5', // zinc-100
			border: '#e4e4e7', // zinc-200
			text: '#09090b', // zinc-950
			textMuted: '#71717a' // zinc-500
		}
	}

	setContext<Theme>('theme', defaultTheme)
</script>

{@render children()}

<style>
	:global(*, *::before, *::after) {
		box-sizing: border-box;
	}

	/* Design token layer - components use these vars for non-themed values.
     Tenant-specific colors go through context (theme.colors.*), not here. */
	:global(:root) {
		--background: #ffffff;
		--foreground: #09090b;
		--card: #ffffff;
		--muted: #f4f4f5;
		--muted-foreground: #71717a;
		--border: #e4e4e7;
		--input: #e4e4e7;
		--radius-sm: 0.375rem;
		--radius: 0.5rem;
		--radius-lg: 0.75rem;
		--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
		--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
		--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
	}

	:global(body) {
		margin: 0;
		font-family:
			'Inter',
			-apple-system,
			BlinkMacSystemFont,
			'Segoe UI',
			sans-serif;
		font-size: 14px;
		line-height: 1.5;
		background: var(--background);
		color: var(--foreground);
		-webkit-font-smoothing: antialiased;
		-moz-osx-font-smoothing: grayscale;
	}

	:global(a) {
		color: inherit;
		text-decoration: none;
	}

	:global(h1, h2, h3, h4, h5, h6) {
		margin: 0;
		line-height: 1.25;
		letter-spacing: -0.02em;
	}

	:global(p) {
		margin: 0;
	}

	:global(code) {
		font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
		font-size: 0.8125em;
		background: var(--muted);
		padding: 0.15em 0.4em;
		border-radius: 0.25rem;
	}
</style>

Step 10: Public Landing Page

The landing page is the first thing a visitor sees. It has two jobs: demonstrate the tenant isolation immediately by rendering the current tenant’s branding, and give developers testing the demo a clear map of which account belongs to which URL. Without this page, hitting / just bounces straight to /login with no context, confusing for anyone following the article.

We need a +page.server.ts to redirect already-authenticated users and pass tenant data down, and a +page.svelte that uses the root layout’s context to reflect the active tenant.

// src/routes/+page.server.ts
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals, url }) => {
	// Authenticated users have nothing to do here - send them to the dashboard
	if (locals.session) {
		redirect(303, '/dashboard')
	}

	// locals.tenant is always set by the hook - the landing page is tenant-aware
	// even though it's public. Visiting acme.localhost shows Acme branding;
	// visiting globex.localhost shows Globex branding.
	//
	// IMPORTANT: we must read url.searchParams here so SvelteKit knows this
	// load function depends on the ?tenant= param. Without this, SvelteKit
	// skips re-running the function on same-route client-side navigation
	// (e.g. clicking a tenant card), and the hero stays stale.
	url.searchParams.get('tenant')

	return {
		tenant: locals.tenant
	}
}
<!-- src/routes/+page.svelte -->
<script lang="ts">
	import TenantLogo from '$lib/components/TenantLogo.svelte'
	import { ArrowRight, Building2, Rocket, Zap } from 'lucide-svelte'
	import type { Tenant, TenantIconName } from '$lib/types/context'

	interface Props {
		data: { tenant: Tenant | null }
	}

	let { data }: Props = $props()

	const demoAccounts: Array<{
		email: string
		role: string
		tenant: string
		iconName: TenantIconName
		color: string
		url: string
		subdomain: string
	}> = [
		{
			email: 'alice@acme.com',
			role: 'admin',
			tenant: 'Acme Corp',
			iconName: 'rocket',
			color: '#ff6452',
			url: 'http://localhost:5173?tenant=acme',
			subdomain: 'http://acme.localhost:5173'
		},
		{
			email: 'bob@acme.com',
			role: 'member',
			tenant: 'Acme Corp',
			iconName: 'rocket',
			color: '#ff6452',
			url: 'http://localhost:5173?tenant=acme',
			subdomain: 'http://acme.localhost:5173'
		},
		{
			email: 'carol@globex.com',
			role: 'owner',
			tenant: 'Globex Inc',
			iconName: 'zap',
			color: '#0ec5e9',
			url: 'http://localhost:5173?tenant=globex',
			subdomain: 'http://globex.localhost:5173'
		},
		{
			email: 'dave@initech.com',
			role: 'member',
			tenant: 'Initech LLC',
			iconName: 'building2',
			color: '#ff7ee1',
			url: 'http://localhost:5173?tenant=initech',
			subdomain: 'http://initech.localhost:5173'
		}
	]

	const primaryColor = $derived(data.tenant?.branding.primaryColor ?? '#ff6452')

	// When a tenant is active, send the user to their subdomain login page so
	// cross-tenant isolation works correctly from the very first click.
	const loginUrl = $derived(
		data.tenant ? `http://${data.tenant.slug}.localhost:5173/login` : '/login'
	)
</script>

<svelte:head>
	<title>{data.tenant?.name ?? 'SaaS Demo'} — Context API Demo</title>
</svelte:head>

<div class="page">
	<!-- Lead hero: large title, no branding yet -->
	<section class="lead-hero">
		<h1>Multi-tenant SaaS with Svelte 5 Context API</h1>
		<p>
			A fully working multi-tenant SaaS demo built with SvelteKit and the Context API. Tenant
			branding, feature flags, and role-based admin — all from context, no prop drilling.
		</p>
	</section>

	<!-- Branded hero: shows the active tenant immediately -->
	<section class="hero" style:background="color-mix(in srgb, {primaryColor} 15%, transparent)">
		<div class="hero-brand">
			<span class="hero-icon">
				<TenantLogo icon={data.tenant?.branding.logoIcon ?? 'rocket'} size={36} />
			</span>
			<h1 style:color={primaryColor}>{data.tenant?.name ?? 'SaaS Demo'}</h1>
		</div>
		{#if data.tenant}
			<div class="hero-chips">
				<span class="chip">plan: <strong>{data.tenant.plan}</strong></span>
				<span class="chip">primary: <code style:color={primaryColor}>{primaryColor}</code></span>
			</div>
		{/if}
		<p class="hero-desc">
			Each tenant has its own isolated data, branding, and feature flags determined by the URL. Sign
			in to see the magic!
		</p>
		<a href={loginUrl} class="btn-primary" style:background={primaryColor}>
			Sign in <ArrowRight size={14} />
		</a>
	</section>

	<!-- Tenant switcher -->
	<section class="card-section">
		<div class="section-header">
			<h2>Each tenant is a separate world</h2>
			<p>
				The URL determines which tenant you're on. The server hook reads the subdomain (or <code
					>?tenant=</code
				> param) and isolates all data, branding, and feature flags before any load function runs.
			</p>
		</div>
		<div class="tenants-grid">
			<a href="http://localhost:5173?tenant=acme" class="tenant-card" style:--color="#ff6452">
				<span class="t-icon"><Rocket size={20} /></span>
				<div class="t-info">
					<strong>Acme Corp</strong>
					<span>enterprise · orange</span>
					<code>?tenant=acme</code>
				</div>
			</a>
			<a href="http://localhost:5173?tenant=globex" class="tenant-card" style:--color="#0ec5e9">
				<span class="t-icon"><Zap size={20} /></span>
				<div class="t-info">
					<strong>Globex Inc</strong>
					<span>pro · sky blue</span>
					<code>?tenant=globex</code>
				</div>
			</a>
			<a href="http://localhost:5173?tenant=initech" class="tenant-card" style:--color="#ff7ee1">
				<span class="t-icon"><Building2 size={20} /></span>
				<div class="t-info">
					<strong>Initech LLC</strong>
					<span>free · pink</span>
					<code>?tenant=initech</code>
				</div>
			</a>
		</div>
	</section>

	<!-- Demo accounts table -->
	<section class="card-section">
		<div class="section-header">
			<h2>Demo accounts</h2>
			<p>
				All passwords are <code>password123</code>. Each account must be accessed from its tenant’s
				URL — cross-tenant credentials are silently rejected.
			</p>
		</div>
		<div class="accounts-table">
			<div class="table-head">
				<span>Email</span><span>Role</span><span>Tenant</span><span>URL to use</span>
			</div>
			{#each demoAccounts as account}
				<div class="table-row">
					<code class="email-cell">{account.email}</code>
					<span class="role-badge" style:background={account.color}>{account.role}</span>
					<span class="tenant-cell">
						<TenantLogo icon={account.iconName} size={14} />
						<span>{account.tenant}</span>
					</span>
					<div class="url-cell">
						<a href={account.url} class="url-link" style:color={account.color}>{account.url}</a>
						<span class="url-sep">or</span>
						<a href={account.subdomain} class="url-link url-alt" style:color={account.color}>
							{account.subdomain}
						</a>
					</div>
				</div>
			{/each}
		</div>
	</section>

	<!-- Subdomain tip -->
	<section class="hint-section">
		<div class="hint-header">
			<svg
				xmlns="http://www.w3.org/2000/svg"
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="none"
				stroke="currentColor"
				stroke-width="2"
			>
				<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line
					x1="12"
					y1="16"
					x2="12.01"
					y2="16"
				/>
			</svg>
			<h2>Two ways to switch tenants</h2>
		</div>
		<p class="hint-desc">
			Use the <code>?tenant=</code> query param, or go straight to the subdomain — modern browsers
			resolve <code>*.localhost</code> automatically, no <code>/etc/hosts</code> setup needed.
		</p>
		<pre class="code-block"># Query param
http://localhost:5173?tenant=acme

# Subdomain (works out of the box)
http://acme.localhost:5173</pre>
	</section>
</div>

<style>
	.page {
		max-width: 880px;
		margin: 0 auto;
		padding: 3rem 1.5rem 5rem;
		display: flex;
		flex-direction: column;
		gap: 2rem;
		/* color: #ff6452; */
	}

	/* Lead hero */
	.lead-hero {
		padding: 1rem 0 0.5rem;
	}

	.lead-hero h1 {
		font-size: clamp(2.5rem, 5vw + 1rem, 5.5rem);
		font-weight: 900;
		letter-spacing: -0.05em;
		line-height: 0.95;
		color: var(--foreground);
		margin-bottom: clamp(0.75rem, 1.5vw, 1.25rem);
	}

	.lead-hero p {
		font-size: clamp(1rem, 1.25vw + 0.5rem, 1.25rem);
		color: var(--muted-foreground);
		line-height: 1.7;
		letter-spacing: -0.01em;
		max-width: 52ch;
	}

	/* Branded hero */
	.hero {
		background: var(--card);
		border: 1px solid var(--border);
		border-radius: var(--radius-lg);
		padding: 2rem 2.5rem;
		box-shadow: var(--shadow);
	}

	.hero-brand {
		display: flex;
		align-items: center;
		gap: 0.875rem;
		margin-bottom: 0.75rem;
	}

	.hero-icon {
		display: flex;
		align-items: center;
		color: var(--foreground);
	}

	.hero-brand h1 {
		font-size: clamp(2.25rem, 4vw + 1rem, 4.5rem);
		font-weight: 900;
		letter-spacing: -0.05em;
		line-height: 1;
	}

	.hero-chips {
		display: flex;
		gap: 0.5rem;
		margin-bottom: 1rem;
	}

	.chip {
		font-size: clamp(0.75rem, 0.4vw + 0.5rem, 0.875rem);
		color: var(--muted-foreground);
		background: var(--background);
		padding: 0.2rem 0.625rem;
		border-radius: var(--radius-sm);
	}

	.hero-desc {
		font-size: clamp(1rem, 1.25vw + 0.5rem, 1.1875rem);
		color: var(--muted-foreground);
		margin-bottom: 1.5rem;
		max-width: 560px;
		line-height: 1.7;
		letter-spacing: -0.005em;
	}

	.btn-primary {
		display: inline-flex;
		align-items: center;
		padding: 0.5625rem 1.25rem;
		color: white;
		border-radius: var(--radius-sm);
		font-weight: 600;
		font-size: clamp(0.9rem, 0.75vw + 0.5rem, 1.0625rem);
		transition:
			opacity 0.15s,
			box-shadow 0.15s;
		box-shadow: var(--shadow-sm);
	}

	.btn-primary:hover {
		opacity: 0.9;
		box-shadow: var(--shadow);
	}

	/* Card sections */
	.card-section {
		background: var(--card);
		border: 1px solid var(--border);
		border-radius: var(--radius-lg);
		padding: 1.5rem;
		box-shadow: var(--shadow-sm);
		display: flex;
		flex-direction: column;
		gap: 1.25rem;
	}

	.section-header h2 {
		font-size: clamp(0.9375rem, 0.75vw + 0.5rem, 1.125rem);
		font-weight: 600;
		margin-bottom: 0.375rem;
		color: var(--foreground);
	}
	.section-header p {
		font-size: clamp(0.875rem, 0.5vw + 0.5rem, 1rem);
		color: var(--muted-foreground);
		line-height: 1.6;
	}

	/* Tenant cards */
	.tenants-grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
		gap: 0.75rem;
	}

	.tenant-card {
		display: flex;
		align-items: center;
		gap: 0.75rem;
		background: var(--background);
		border: 1px solid var(--border);
		border-radius: var(--radius);
		padding: 0.875rem 1rem;
		transition:
			box-shadow 0.15s,
			transform 0.15s;
	}

	.tenant-card:hover {
		background: color-mix(in srgb, var(--color) 8%, transparent);
	}

	.t-icon {
		display: flex;
		align-items: center;
		color: var(--foreground);
		flex-shrink: 0;
	}

	.t-info {
		display: flex;
		flex-direction: column;
		gap: 0.125rem;
	}
	.t-info strong {
		font-size: clamp(0.875rem, 0.5vw + 0.5rem, 1rem);
		font-weight: 600;
		color: var(--foreground);
	}
	.t-info span {
		font-size: clamp(0.6875rem, 0.3vw + 0.5rem, 0.8125rem);
		color: var(--muted-foreground);
	}
	.t-info code {
		font-size: clamp(0.625rem, 0.3vw + 0.4rem, 0.75rem);
		color: var(--color);
		background: color-mix(in srgb, var(--color) 8%, transparent);
		padding: 0.125rem 0.375rem;
		border-radius: 0.25rem;
	}

	/* Accounts table */
	.accounts-table {
		border: 1px solid var(--border);
		border-radius: var(--radius);
		overflow: hidden;
	}

	.table-head {
		display: grid;
		grid-template-columns: 2fr 0.75fr 1.25fr 2.5fr;
		gap: 0.5rem;
		padding: 0.625rem 1rem;
		background: var(--muted);
		color: var(--muted-foreground);
		font-size: 0.6875rem;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.06em;
	}

	.table-row {
		display: grid;
		grid-template-columns: 2fr 0.75fr 1.25fr 2.5fr;
		gap: 0.5rem;
		align-items: center;
		padding: 0.625rem 1rem;
		border-top: 1px solid var(--border);
		font-size: clamp(0.8125rem, 0.5vw + 0.5rem, 0.9375rem);
	}

	.table-row:hover {
		background: var(--muted);
	}

	.email-cell {
		color: var(--foreground);
		font-size: clamp(0.75rem, 0.4vw + 0.45rem, 0.875rem);
	}

	.role-badge {
		display: inline-flex;
		justify-content: center;
		font-size: 0.625rem;
		font-weight: 700;
		color: white;
		padding: 0.2rem 0.5rem;
		border-radius: 999px;
		text-transform: uppercase;
		letter-spacing: 0.05em;
		white-space: nowrap;
	}

	.tenant-cell {
		display: flex;
		align-items: center;
		gap: 0.375rem;
		font-size: clamp(0.75rem, 0.4vw + 0.45rem, 0.875rem);
		color: var(--foreground);
	}

	.url-cell {
		display: flex;
		flex-direction: column;
		gap: 0.125rem;
	}

	.url-link {
		font-size: clamp(0.6875rem, 0.3vw + 0.45rem, 0.8125rem);
		font-family: ui-monospace, monospace;
		transition: opacity 0.15s;
	}
	.url-link:hover {
		text-decoration: underline;
	}

	.url-sep {
		font-size: 0.625rem;
		color: var(--muted-foreground);
	}
	.url-alt {
		opacity: 0.65;
	}

	/* Hint section */
	.hint-section {
		background: var(--muted);
		border: 1px solid #b6b6b6;
		border-radius: var(--radius-lg);
		padding: 1.25rem 1.5rem;
		display: flex;
		flex-direction: column;
		gap: 0.75rem;
	}

	.hint-header {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		color: #fa1b53;
	}

	.hint-header h2 {
		font-size: clamp(0.875rem, 0.75vw + 0.5rem, 1.0625rem);
		font-weight: 600;
	}

	.hint-desc {
		font-size: clamp(0.875rem, 0.5vw + 0.5rem, 1rem);
		color: #2e2e37;
		line-height: 1.6;
	}

	.code-block {
		background: #09090b;
		color: #e4e4e7;
		padding: 0.875rem 1.125rem;
		border-radius: var(--radius);
		font-size: clamp(0.75rem, 0.4vw + 0.5rem, 0.875rem);
		font-family: ui-monospace, monospace;
		line-height: 1.75;
		margin: 0;
		overflow-x: auto;
	}
</style>

What this page demonstrates immediately. Before the reader logs in, the lead-hero gives a clear description of what the demo is, and the branded hero section already shows the Context API working, visit ?tenant=acme and it renders with Acme’s orange-red branding and a Rocket icon. Switch to ?tenant=globex, same page, same component files, cyan and a Zap icon. The tenant branding is live in the URL bar before a single login attempt.

The url.searchParams.get('tenant') read in the load function is crucial: without it, SvelteKit won’t track the ?tenant= param as a dependency, and clicking the tenant cards on the landing page won’t trigger a server re-run, the hero stays stale on the old tenant.


Step 11: Dashboard Layout - Tenant Context

This is where the key shadowing happens. The root set theme to a default. This layout shadows it with tenant branding. Notice the file lives at src/routes/(app)/dashboard/+layout.svelte — it only applies to the dashboard subtree, not to every route inside the (app) group.

<!-- src/routes/(app)/dashboard/+layout.svelte -->
<script lang="ts">
	import { getContext, setContext } from 'svelte'
	import AppHeader from '$lib/components/AppHeader.svelte'
	import AppSidebar from '$lib/components/AppSidebar.svelte'
	import type { Features, Tenant, Theme } from '$lib/types/context'

	interface Props {
		data: { tenant: Tenant; features: Features }
		children: import('svelte').Snippet
	}

	let { data, children }: Props = $props()

	// Set tenant and features using getter objects so context always reflects
	// the *current* value of data - not only the initial snapshot.
	//
	// Why getters? data comes from $props(), which is reactive. Reading
	// data.tenant directly at the top-level script body - outside any reactive
	// context - means Svelte can only capture the value once at initialization.
	// If SvelteKit re-runs the load function (e.g. after invalidateAll() or a
	// form action), data updates but the context value would silently stay stale.
	//
	// Wrapping each read in a getter closure defers the access to call time.
	// Svelte tracks the dependency correctly, and consumers always get the
	// live value via .current.
	setContext<{ readonly current: Tenant }>('tenant', {
		get current() {
			return data.tenant
		}
	})
	setContext<{ readonly current: Features }>('features', {
		get current() {
			return data.features
		}
	})

	// Get the default theme from the root layout
	const defaultTheme = getContext<Theme>('theme')

	// Build a tenant-branded theme - same 'theme' key, shadowed value.
	// Any component in (app)/dashboard/* that calls getContext('theme') gets this,
	// NOT the root default.
	//
	// Spread defaultTheme.colors first so any future color properties added to
	// the Theme interface are automatically inherited here without a manual update.
	// Then override only the brand-specific properties with getters so the theme
	// stays in sync if data updates after SvelteKit invalidation.
	const tenantTheme: Theme = {
		...defaultTheme,
		colors: {
			...defaultTheme.colors,
			// Updated zinc-based neutral palette
			background: '#ffffff',
			surface: '#f4f4f5', // zinc-100
			border: '#e4e4e7', // zinc-200
			text: '#09090b', // zinc-950
			textMuted: '#71717a', // zinc-500
			get primary() {
				return data.tenant.branding.primaryColor
			},
			get accent() {
				return data.tenant.branding.accentColor
			}
		}
	}

	setContext<Theme>('theme', tenantTheme)
</script>

<div class="app-shell">
	<AppHeader />

	<div class="app-body">
		<AppSidebar />
		<main class="app-content">
			{@render children()}
		</main>
	</div>
</div>

<style>
	.app-shell {
		display: flex;
		flex-direction: column;
		min-height: 100vh;
		background: var(--muted);
	}

	.app-body {
		display: flex;
		flex: 1;
	}

	.app-content {
		flex: 1;
		padding: 2rem 2.5rem;
		overflow-y: auto;
		min-width: 0;
	}
</style>

What just happened with setContext('theme', tenantTheme)?

The root layout already called setContext('theme', defaultTheme). When this layout calls setContext('theme', tenantTheme), it does not replace the root’s context. It creates a new entry in the context map for this subtree. Components inside (app)/dashboard/* get tenantTheme; components outside that path (login, landing page) still get defaultTheme. This is context shadowing — the same mechanism that lets you nest providers without global state pollution.

The ...defaultTheme.colors spread on line 3 of tenantTheme.colors is a subtle but important detail. Without it, if a new property is ever added to the Theme.colors interface, this object literal would be missing it and TypeScript would only catch the omission at the type assignment — not here. The spread ensures the tenant theme always inherits every neutral colour from the default, then only overrides the brand-specific ones.


Step 12: App Components

Instead of storing emoji strings in the DB, the project uses lucide-svelte icons mapped by a string key. This keeps the DB schema serialisable (no Unicode oddities) and gives crisp SVG icons at any size.

Add the dependency first:

npm install lucide-svelte
SSR fix required for lucide-svelte

lucide-svelte ships extensionless ESM imports (e.g. './icons/index' instead of './icons/index.js'), which break Node’s strict ESM resolver during SSR. You must tell Vite to bundle it instead of externalising it.

// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'

export default defineConfig({
	plugins: [sveltekit()],
	ssr: {
		// lucide-svelte ships extensionless ESM imports that break Node's strict
		// ESM resolver. Bundling via Vite works around this.
		noExternal: ['lucide-svelte']
	}
})

Without this, the dev server will throw ERR_MODULE_NOT_FOUND the moment any route imports a lucide icon server-side.

<!-- src/lib/components/TenantLogo.svelte -->
<script lang="ts">
	import { Building2, Hexagon, Rocket, Shield, Zap } from 'lucide-svelte'
	import type { TenantIconName } from '$lib/types/context'

	interface Props {
		icon: TenantIconName | string
		size?: number
	}

	let { icon, size = 24 }: Props = $props()
</script>

{#if icon === 'rocket'}
	<Rocket {size} />
{:else if icon === 'zap'}
	<Zap {size} />
{:else if icon === 'building2'}
	<Building2 {size} />
{:else if icon === 'shield'}
	<Shield {size} />
{:else}
	<Hexagon {size} />
{/if}

The shield icon is used by the internal tenant_platform tenant so the superadmin’s login and admin nav show a distinct brand mark. The | string on the prop type is a safety valve, if a new icon name is added to the DB before the component is updated, the fallback Hexagon renders instead of a runtime error.

AppHeader

<!-- src/lib/components/AppHeader.svelte -->
<script lang="ts">
	import { getContext } from 'svelte'
	import TenantLogo from '$lib/components/TenantLogo.svelte'
	import type { Tenant, Theme, UserSession } from '$lib/types/context'

	const theme = getContext<Theme>('theme')
	const tenantCtx = getContext<{ readonly current: Tenant }>('tenant')
	const sessionCtx = getContext<{ readonly current: UserSession | null }>('session')

	const tenant = $derived(tenantCtx.current)
	const session = $derived(sessionCtx.current)
</script>

<header class="app-header">
	<div class="brand">
		<span class="logo-icon"><TenantLogo icon={tenant.branding.logoIcon} size={20} /></span>
		<span class="tenant-name">{tenant.name}</span>
		<span class="plan-badge" style:background={theme.colors.primary}>{tenant.plan}</span>
	</div>

	<nav class="nav-links">
		<a href="/dashboard" class="nav-link">Dashboard</a>
		{#if session?.role === 'admin' || session?.role === 'owner' || session?.role === 'superadmin'}
			<a href="/admin/panel" class="nav-link nav-link-admin" style:color={theme.colors.accent}>
				Admin Panel
			</a>
		{/if}
	</nav>

	<div class="user-area">
		{#if session}
			<div class="user-pill">
				<span class="avatar" style:background={theme.colors.primary}>
					{session.name.charAt(0).toUpperCase()}
				</span>
				<div class="user-meta">
					<span class="user-name">{session.name}</span>
					<span class="user-role">{session.role}</span>
				</div>
			</div>
			<form method="POST" action="/logout">
				<button type="submit" class="btn-ghost">Sign out</button>
			</form>
		{/if}
	</div>
</header>

<style>
	.app-header {
		display: flex;
		align-items: center;
		gap: 1.5rem;
		padding: 0 1.5rem;
		height: 3.5rem;
		background: var(--card);
		border-bottom: 1px solid var(--border);
		position: sticky;
		top: 0;
		z-index: 10;
		box-shadow: var(--shadow-sm);
	}

	.brand {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		flex-shrink: 0;
	}

	.logo-icon {
		display: flex;
		align-items: center;
		color: var(--foreground);
	}

	.tenant-name {
		font-size: 0.9375rem;
		font-weight: 700;
		color: var(--foreground);
		letter-spacing: -0.01em;
	}

	.plan-badge {
		font-size: 0.625rem;
		font-weight: 600;
		color: white;
		padding: 0.2rem 0.5rem;
		border-radius: 999px;
		text-transform: uppercase;
		letter-spacing: 0.06em;
	}

	.nav-links {
		display: flex;
		gap: 0.25rem;
		flex: 1;
	}

	.nav-link {
		font-size: 0.875rem;
		font-weight: 500;
		color: var(--muted-foreground);
		padding: 0.375rem 0.625rem;
		border-radius: var(--radius-sm);
		transition:
			background 0.15s,
			color 0.15s;
	}

	.nav-link:hover {
		background: var(--muted);
		color: var(--foreground);
	}

	.nav-link-admin {
		font-weight: 600;
	}

	.user-area {
		display: flex;
		align-items: center;
		gap: 0.75rem;
		margin-left: auto;
	}

	.user-pill {
		display: flex;
		align-items: center;
		gap: 0.625rem;
	}

	.avatar {
		width: 2rem;
		height: 2rem;
		border-radius: 50%;
		color: white;
		display: flex;
		align-items: center;
		justify-content: center;
		font-weight: 700;
		font-size: 0.8125rem;
		flex-shrink: 0;
	}

	.user-meta {
		display: flex;
		flex-direction: column;
		line-height: 1.2;
	}

	.user-name {
		font-size: 0.8125rem;
		font-weight: 600;
		color: var(--foreground);
	}

	.user-role {
		font-size: 0.6875rem;
		color: var(--muted-foreground);
		text-transform: capitalize;
	}

	.btn-ghost {
		background: transparent;
		border: 1px solid var(--border);
		border-radius: var(--radius-sm);
		padding: 0.3125rem 0.75rem;
		font-size: 0.8125rem;
		font-weight: 500;
		cursor: pointer;
		color: var(--muted-foreground);
		transition:
			background 0.15s,
			color 0.15s;
		font-family: inherit;
	}

	.btn-ghost:hover {
		background: var(--muted);
		color: var(--foreground);
	}
</style>

AppSidebar

<!-- src/lib/components/AppSidebar.svelte -->
<script lang="ts">
	import { Activity, ArrowRight, Code, Globe, LayoutDashboard, Settings } from 'lucide-svelte'
	import { getContext } from 'svelte'
	import type { Features, Theme } from '$lib/types/context'

	const theme = getContext<Theme>('theme')
	const featuresCtx = getContext<{ readonly current: Features }>('features')
	const features = $derived(featuresCtx.current)
</script>

<aside class="sidebar">
	<nav class="sidebar-nav">
		<div class="section-label">Main</div>
		<a href="/dashboard" class="nav-item" style:--primary={theme.colors.primary}>
			<LayoutDashboard size={16} />
			Dashboard
		</a>

		{#if features.advancedReporting}
			<a href="/dashboard" class="nav-item" style:--primary={theme.colors.primary}>
				<Activity size={16} />
				Reports
			</a>
		{/if}

		{#if features.apiAccess}
			<a href="/dashboard" class="nav-item" style:--primary={theme.colors.primary}>
				<Code size={16} />
				API
			</a>
		{/if}

		<div class="section-label">Account</div>

		{#if features.customDomains}
			<a href="/dashboard" class="nav-item" style:--primary={theme.colors.primary}>
				<Globe size={16} />
				Domains
			</a>
		{/if}

		<a href="/dashboard" class="nav-item" style:--primary={theme.colors.primary}>
			<Settings size={16} />
			Settings
		</a>
	</nav>

	{#if !features.prioritySupport}
		<div class="upgrade-nudge" style:--primary={theme.colors.primary}>
			<p class="nudge-title">Upgrade your plan</p>
			<p class="nudge-desc">Get priority support and more features.</p>
			<a href="/dashboard" class="nudge-link">Learn more <ArrowRight size={11} /></a>
		</div>
	{/if}
</aside>

<style>
	.sidebar {
		width: 240px;
		flex-shrink: 0;
		background: var(--card);
		border-right: 1px solid var(--border);
		padding: 1rem 0;
		display: flex;
		flex-direction: column;
		gap: 0;
	}

	.sidebar-nav {
		flex: 1;
		padding: 0 0.75rem;
		display: flex;
		flex-direction: column;
		gap: 0.125rem;
	}

	.section-label {
		font-size: 0.6875rem;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.08em;
		color: var(--muted-foreground);
		padding: 0.5rem 0.625rem 0.25rem;
		margin-top: 0.5rem;
	}

	.section-label:first-child {
		margin-top: 0;
	}

	.nav-item {
		display: flex;
		align-items: center;
		gap: 0.625rem;
		padding: 0.4375rem 0.625rem;
		font-size: 0.875rem;
		font-weight: 500;
		color: var(--muted-foreground);
		border-radius: var(--radius-sm);
		transition:
			background 0.15s,
			color 0.15s;
	}

	.nav-item :global(svg) {
		flex-shrink: 0;
		opacity: 0.7;
	}

	.nav-item:hover {
		background: color-mix(in srgb, var(--primary) 8%, transparent);
		color: var(--primary);
	}

	.nav-item:hover :global(svg) {
		opacity: 1;
	}

	.upgrade-nudge {
		margin: 0.75rem 0.75rem 0.25rem;
		padding: 0.875rem 1rem;
		background: color-mix(in srgb, var(--primary) 6%, transparent);
		border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
		border-radius: var(--radius);
	}

	.nudge-title {
		font-size: 0.8125rem;
		font-weight: 600;
		color: var(--foreground);
		margin-bottom: 0.25rem;
	}

	.nudge-desc {
		font-size: 0.75rem;
		color: var(--muted-foreground);
		margin-bottom: 0.5rem;
	}

	.nudge-link {
		display: inline-flex;
		align-items: center;
		gap: 0.25rem;
		font-size: 0.75rem;
		font-weight: 600;
		color: var(--primary);
	}
</style>

StatsCard

<!-- src/lib/components/StatsCard.svelte -->
<script lang="ts">
	import { getContext } from 'svelte'
	import type { ComponentType, SvelteComponent } from 'svelte'
	import type { Theme } from '$lib/types/context'

	interface Props {
		title: string
		value: string | number
		icon?: ComponentType<SvelteComponent> // lucide-svelte icon component
		note?: string
	}

	let { title, value, icon: IconComponent, note }: Props = $props()
	const theme = getContext<Theme>('theme')
</script>

<div class="stat-card">
	<div class="stat-header">
		<span class="stat-title">{title}</span>
		{#if IconComponent}
			<span class="stat-icon"><IconComponent size={16} /></span>
		{/if}
	</div>
	<div class="stat-value" style:color={theme.colors.primary}>{value}</div>
	{#if note}<p class="stat-note">{note}</p>{/if}
</div>

<style>
	.stat-card {
		background: var(--card);
		border: 1px solid var(--border);
		border-radius: var(--radius-lg);
		padding: 1.25rem 1.5rem;
		box-shadow: var(--shadow-sm);
	}

	.stat-header {
		display: flex;
		align-items: center;
		justify-content: space-between;
		margin-bottom: 0.875rem;
	}

	.stat-title {
		font-size: 0.8125rem;
		font-weight: 500;
		color: var(--muted-foreground);
		letter-spacing: 0.01em;
	}

	.stat-icon {
		display: flex;
		align-items: center;
		color: var(--muted-foreground);
		opacity: 0.6;
	}

	.stat-value {
		font-size: 2rem;
		font-weight: 700;
		line-height: 1;
		letter-spacing: -0.03em;
	}

	.stat-note {
		font-size: 0.75rem;
		color: var(--muted-foreground);
		margin-top: 0.375rem;
	}
</style>

The icon is now a component reference, not a string. Pass it like:

<script lang="ts">
	import { Users, Activity } from 'lucide-svelte'
</script>

<StatsCard title="Active Users" value={12} icon={Users} />
<StatsCard title="Live Sessions" value={3} icon={Activity} />

RequireFeature

<!-- src/lib/components/RequireFeature.svelte -->
<script lang="ts">
	import { getContext } from 'svelte'
	import type { Features } from '$lib/types/context'

	interface Props {
		feature: keyof Features
		fallback?: import('svelte').Snippet
		children: import('svelte').Snippet
	}

	let { feature, fallback, children }: Props = $props()

	const featuresCtx = getContext<{ readonly current: Features }>('features')
	const hasFeature = $derived(featuresCtx.current[feature])
</script>

{#if hasFeature}
	{@render children()}
{:else if fallback}
	{@render fallback()}
{/if}

Usage:

<!-- Single feature gate -->
<RequireFeature feature="advancedReporting">
	<ReportBuilder />
</RequireFeature>

<!-- With upgrade nudge fallback -->
{#snippet upgradePrompt()}
	<p>Upgrade your plan to access Advanced Reports.</p>
{/snippet}

<RequireFeature feature="advancedReporting" fallback={upgradePrompt}>
	<ReportBuilder />
</RequireFeature>
RequireFeature is UI-only

This hides elements. It does not protect API endpoints. A user could remove the element from the DOM and still hit /api/reports, your server endpoints must independently check locals.features (or re-derive them) for any sensitive operations.


Step 13: Dashboard Page

// src/routes/(app)/dashboard/+page.server.ts

import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
	if (!locals.session) redirect(303, '/login')

	// In a real app: query DB for this tenant's stats
	// We return plausible mock numbers here
	return {
		stats: {
			users: 12,
			sessions: 3,
			apiCalls: 14_823,
			documents: 47
		}
	}
}
<!-- src/routes/(app)/dashboard/+page.svelte -->
<script lang="ts">
	import { getContext } from 'svelte'
	import RequireFeature from '$lib/components/RequireFeature.svelte'
	import StatsCard from '$lib/components/StatsCard.svelte'
	import { Activity, FileText, Plug, Users } from 'lucide-svelte'
	import type { Features, Tenant, Theme } from '$lib/types/context'

	interface Props {
		data: {
			stats: { users: number; sessions: number; apiCalls: number; documents: number }
		}
	}

	let { data }: Props = $props()

	const theme = getContext<Theme>('theme')
	const tenantCtx = getContext<{ readonly current: Tenant }>('tenant')
	const featuresCtx = getContext<{ readonly current: Features }>('features')

	const tenant = $derived(tenantCtx.current)
	const features = $derived(featuresCtx.current)
</script>

<svelte:head>
	<title>Dashboard — {tenant.name}</title>
</svelte:head>

<div class="dashboard">
	<header class="page-header">
		<h1 style:color={theme.colors.primary}>{tenant.name}</h1>
		<div class="header-chips">
			<span class="chip">plan: <strong>{tenant.plan}</strong></span>
			<span class="chip"
				>primary: <code style:color={theme.colors.primary}>{theme.colors.primary}</code></span
			>
		</div>
	</header>

	<!-- Stats grid - some cards only appear with the right features -->
	<div class="stats-grid">
		<StatsCard title="Active Users" value={data.stats.users} icon={Users} />
		<StatsCard title="Live Sessions" value={data.stats.sessions} icon={Activity} />

		<RequireFeature feature="apiAccess">
			<StatsCard
				title="API Calls"
				value={data.stats.apiCalls.toLocaleString()}
				icon={Plug}
				note="last 30 days"
			/>
		</RequireFeature>

		<RequireFeature feature="advancedReporting">
			<StatsCard title="Documents" value={data.stats.documents} icon={FileText} />
		</RequireFeature>
	</div>

	<!-- Feature flags -->
	<section class="card">
		<div class="card-header">
			<div>
				<h2>Available Features</h2>
				<p class="card-desc">
					Resolved from the in-memory DB for tenant <code>{tenant.id}</code> (plan: {tenant.plan}).
					Context distributes them - no prop drilling.
				</p>
			</div>
		</div>
		<div class="feature-list">
			{#each Object.entries(features) as [key, enabled]}
				<div class="feature-row" class:enabled>
					<span
						class="feature-dot"
						style:background={enabled ? theme.colors.primary : 'var(--border)'}
					></span>
					<code class="feature-key">{key}</code>
					<span
						class="feature-badge"
						class:badge-enabled={enabled}
						style:background={enabled
							? `color-mix(in srgb, ${theme.colors.primary} 12%, transparent)`
							: ''}
						style:color={enabled ? theme.colors.primary : ''}
					>
						{enabled ? 'Enabled' : 'Disabled'}
					</span>
				</div>
			{/each}
		</div>
	</section>

	<!-- Context layer explainer -->
	<section class="card">
		<h2>Context layers active on this page</h2>
		<p class="card-desc">How the theme is resolved through nested context providers.</p>
		<ol class="explainer-list">
			<li>
				<strong>Root +layout.svelte</strong> set <code>theme</code> to the default orange-red
				palette,
				<code>appConfig</code>, and <code>session</code>.
			</li>
			<li>
				<strong>(app)/+layout.svelte</strong> shadowed <code>theme</code> with tenant branding
				(primary: <code style:color={theme.colors.primary}>{theme.colors.primary}</code>), and set
				<code>tenant</code> and <code>features</code>.
			</li>
			<li>
				<strong>This page</strong> called <code>getContext('theme')</code> and got the tenant branding
				— not the default - because context shadowing resolves to the nearest ancestor.
			</li>
		</ol>
		<p class="explainer-note">
			Switch to <code>globex.localhost</code> for sky-blue or <code>initech.localhost</code> for pink.
			Same components, different context values.
		</p>
	</section>
</div>

<style>
	.dashboard {
		max-width: 880px;
		display: flex;
		flex-direction: column;
		gap: 1.5rem;
	}

	.page-header {
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}

	.page-header h1 {
		font-size: 1.75rem;
		font-weight: 800;
		letter-spacing: -0.04em;
	}

	.header-chips {
		display: flex;
		gap: 0.5rem;
	}

	.chip {
		font-size: 0.8125rem;
		color: var(--muted-foreground);
		background: var(--card);
		border: 1px solid var(--border);
		padding: 0.2rem 0.625rem;
		border-radius: var(--radius-sm);
	}

	.stats-grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
		gap: 1rem;
	}

	/* Cards */
	.card {
		background: var(--card);
		border: 1px solid var(--border);
		border-radius: var(--radius-lg);
		padding: 1.5rem;
		box-shadow: var(--shadow-sm);
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.card-header {
		display: flex;
		align-items: flex-start;
		justify-content: space-between;
	}

	.card h2 {
		font-size: 0.9375rem;
		font-weight: 600;
		color: var(--foreground);
		margin-bottom: 0.25rem;
	}

	.card-desc {
		font-size: 0.875rem;
		color: var(--muted-foreground);
		line-height: 1.5;
	}

	/* Feature flags */
	.feature-list {
		display: flex;
		flex-direction: column;
		gap: 0.125rem;
	}

	.feature-row {
		display: flex;
		align-items: center;
		gap: 0.625rem;
		padding: 0.5rem 0.75rem;
		border-radius: var(--radius-sm);
		font-size: 0.875rem;
		color: var(--muted-foreground);
		transition: background 0.15s;
	}

	.feature-row:hover {
		background: var(--muted);
	}
	.feature-row.enabled {
		color: var(--foreground);
	}

	.feature-dot {
		width: 0.5rem;
		height: 0.5rem;
		border-radius: 50%;
		flex-shrink: 0;
		transition: background 0.2s;
	}

	.feature-key {
		flex: 1;
		font-size: 0.8125rem;
	}

	.feature-badge {
		font-size: 0.6875rem;
		font-weight: 600;
		padding: 0.2rem 0.5rem;
		border-radius: 999px;
		background: var(--muted);
		color: var(--muted-foreground);
	}

	/* Explainer */
	.explainer-list {
		padding-left: 1.25rem;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
		margin: 0;
	}

	.explainer-list li {
		font-size: 0.875rem;
		color: var(--muted-foreground);
		line-height: 1.6;
	}

	.explainer-note {
		font-size: 0.875rem;
		color: var(--muted-foreground);
		padding-top: 0.5rem;
		border-top: 1px solid var(--border);
		line-height: 1.6;
	}
</style>

Step 14: Admin Route Group Layout - Dark Theme Shadow

<!-- src/routes/(admin)/admin/+layout.svelte -->
<script lang="ts">
	import { getContext, setContext } from 'svelte'
	import AdminNav from '$lib/components/AdminNav.svelte'
	import type { Theme } from '$lib/types/context'

	interface Props {
		data: {
			adminStats: {
				totalTenants: number
				totalUsers: number
				activeSessions: number
				tenantName: string
				isSuperAdmin: boolean
			}
		}
		children: import('svelte').Snippet
	}

	let { data, children }: Props = $props()

	// Get the tenant-branded theme from (app)/dashboard/+layout.svelte
	const tenantTheme = getContext<Theme>('theme')

	// Derive adminStats so the prop reference stays reactive.
	// AdminNav receives this as a prop - if data re-runs after an admin
	// action, the prop passed to AdminNav will reflect the fresh value.
	const adminStats = $derived(data.adminStats)

	// Shadow it with dark mode - preserving tenant brand colors.
	// Uses zinc rather than slate for a more neutral, less blue-tinted dark palette.
	// Any component inside (admin)/* gets this theme, not the tenant one.
	const adminTheme: Theme = {
		mode: 'dark',
		colors: {
			primary: tenantTheme.colors.primary, // Keep tenant brand
			accent: tenantTheme.colors.accent, // Keep tenant brand
			background: '#09090b', // zinc-950
			surface: '#18181b', // zinc-900
			border: '#27272a', // zinc-800
			text: '#fafafa', // zinc-50
			textMuted: '#a1a1aa' // zinc-400
		}
	}

	setContext<Theme>('theme', adminTheme)

	// Getter wrapper for the same reason as tenant and features in (app)/dashboard/+layout.svelte:
	// data is reactive ($props), reading data.adminStats directly at init captures
	// only the initial snapshot. The getter defers the read so any re-run of the
	// admin load function (e.g. after an admin action invalidates the route) is
	// automatically reflected in every consumer via .current.
	setContext<{ readonly current: typeof data.adminStats }>('adminStats', {
		get current() {
			return data.adminStats
		}
	})
</script>

<div
	class="admin-shell"
	style:background={adminTheme.colors.background}
	style:color={adminTheme.colors.text}
>
	<AdminNav stats={data.adminStats} />
	<main class="admin-content">
		{@render children()}
	</main>
</div>

<style>
	.admin-shell {
		display: flex;
		min-height: 100vh;
	}

	.admin-content {
		flex: 1;
		padding: 2rem 2.5rem;
		overflow-y: auto;
		min-width: 0;
	}
</style>

AdminNav

AdminNav receives isSuperAdmin from the layout and uses it in three places: the stats grid column count (superadmins show a Tenants counter that tenant admins don’t have), the navigation links (superadmins go to /admin/panel, tenant admins go to /admin/members), and the footer badge (superadmins see “Platform Admin”, tenant admins see their tenant name).

<!-- src/lib/components/AdminNav.svelte -->
<script lang="ts">
	import { ArrowLeft, LayoutDashboard, Shield, Users, User } from 'lucide-svelte'
	import { getContext } from 'svelte'
	import type { Theme } from '$lib/types/context'

	interface Props {
		stats: {
			totalTenants: number
			totalUsers: number
			activeSessions: number
			tenantName: string
			isSuperAdmin: boolean
		}
	}

	let { stats }: Props = $props()
	const theme = getContext<Theme>('theme')

	// Alternatively, AdminNav could read adminStats directly from context
	// via getContext<{ readonly current: ... }>('adminStats').current
	// but receiving it as a prop from the layout keeps the component
	// decoupled from the context key name — easier to test in isolation.
</script>

<aside
	class="admin-nav"
	style:background={theme.colors.surface}
	style:border-right-color={theme.colors.border}
>
	<div class="admin-brand">
		<Shield size={16} color={theme.colors.primary} />
		<span style:color={theme.colors.text}>Admin Panel</span>
	</div>

	<div
		class="stats-grid"
		class:two-col={!stats.isSuperAdmin}
		style:border-color={theme.colors.border}
	>
		{#if stats.isSuperAdmin}
			<div class="mini-stat">
				<span class="mini-value" style:color={theme.colors.primary}>{stats.totalTenants}</span>
				<span class="mini-label" style:color={theme.colors.textMuted}>Tenants</span>
			</div>
		{/if}
		<div class="mini-stat">
			<span class="mini-value" style:color={theme.colors.primary}>{stats.totalUsers}</span>
			<span class="mini-label" style:color={theme.colors.textMuted}>
				{stats.isSuperAdmin ? 'Users' : 'Members'}
			</span>
		</div>
		<div class="mini-stat">
			<span class="mini-value" style:color={theme.colors.primary}>{stats.activeSessions}</span>
			<span class="mini-label" style:color={theme.colors.textMuted}>Sessions</span>
		</div>
	</div>

	<nav class="admin-nav-links">
		{#if !stats.isSuperAdmin}
			<a
				href="/dashboard"
				class="nav-link"
				style:color={theme.colors.textMuted}
				style:--hover-bg={theme.colors.border}
			>
				<ArrowLeft size={14} />
				Dashboard
			</a>
		{/if}

		<div class="nav-divider" style:background={theme.colors.border}></div>

		{#if stats.isSuperAdmin}
			<a
				href="/admin/panel"
				class="nav-link"
				style:color={theme.colors.textMuted}
				style:--hover-bg={theme.colors.border}
			>
				<LayoutDashboard size={14} />
				All Tenants
			</a>
		{:else}
			<a
				href="/admin/members"
				class="nav-link"
				style:color={theme.colors.textMuted}
				style:--hover-bg={theme.colors.border}
			>
				<Users size={14} />
				Members
			</a>
		{/if}
	</nav>

	<div
		class="tenant-badge"
		style:border-color={theme.colors.border}
		style:color={theme.colors.textMuted}
	>
		<User size={12} />
		{#if stats.isSuperAdmin}
			<strong style:color={theme.colors.text}>Platform Admin</strong>
		{:else}
			Viewing: <strong style:color={theme.colors.text}>{stats.tenantName}</strong>
		{/if}
	</div>
</aside>

<style>
	.admin-nav {
		width: 240px;
		flex-shrink: 0;
		border-right: 1px solid;
		display: flex;
		flex-direction: column;
		gap: 0;
		padding: 1.5rem 0;
	}

	.admin-brand {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		font-weight: 700;
		font-size: 0.875rem;
		padding: 0 1.25rem 1.25rem;
		letter-spacing: -0.01em;
	}

	.stats-grid {
		display: grid;
		grid-template-columns: repeat(3, 1fr);
		gap: 0;
		padding: 1rem 1rem 1.25rem;
		border-top: 1px solid;
		border-bottom: 1px solid;
		margin-bottom: 0.75rem;
	}

	/* Tenant admins don’t have a Tenants stat — two columns fit better */
	.stats-grid.two-col {
		grid-template-columns: repeat(2, 1fr);
	}

	.mini-stat {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 0.125rem;
	}

	.mini-value {
		font-size: 1.375rem;
		font-weight: 700;
		letter-spacing: -0.04em;
	}

	.mini-label {
		font-size: 0.625rem;
		text-transform: uppercase;
		letter-spacing: 0.06em;
	}

	.admin-nav-links {
		flex: 1;
		padding: 0 0.75rem;
		display: flex;
		flex-direction: column;
		gap: 0.125rem;
	}

	.nav-link {
		display: flex;
		align-items: center;
		gap: 0.625rem;
		padding: 0.4375rem 0.625rem;
		font-size: 0.875rem;
		font-weight: 500;
		border-radius: var(--radius-sm);
		transition:
			background 0.15s,
			color 0.15s;
	}

	.nav-link:hover {
		background: var(--hover-bg);
	}

	.nav-divider {
		height: 1px;
		margin: 0.375rem 0.625rem;
	}

	.tenant-badge {
		margin-top: auto;
		padding: 0.75rem 1.25rem;
		border-top: 1px solid;
		font-size: 0.75rem;
		display: flex;
		align-items: center;
		gap: 0.375rem;
	}
</style>

Step 15: Admin Panel Page

/admin/panel is the platform-wide overview — it shows every customer tenant and their feature flag matrix. Only the superadmin account should reach this page. Tenant-level admins (owner, admin) are redirected to /admin/members instead, which shows only their own tenant’s membership.

// src/routes/(admin)/admin/panel/+page.server.ts

import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
import { db } from '$lib/server/db'

export const load: PageServerLoad = async ({ locals }) => {
	const { session } = locals

	// Only the platform superadmin sees all tenants — tenant admins go to /admin/members
	if (session?.role !== 'superadmin') {
		redirect(303, '/admin/members')
	}

	// customer() excludes the internal platform tenant so it doesn’t appear in its own table
	const customerTenants = db.tenants.customer()

	return {
		tenants: customerTenants.map((t) => ({
			id: t.id,
			name: t.name,
			slug: t.slug,
			plan: t.plan,
			features: db.features.forTenant(t.id)
		}))
	}
}
<!-- src/routes/(admin)/admin/panel/+page.svelte -->
<script lang="ts">
	import { getContext } from 'svelte'
	import type { Theme } from '$lib/types/context'
	import type { Features } from '$lib/types/context'

	interface TenantRow {
		id: string
		name: string
		slug: string
		plan: string
		features: Features
	}

	interface Props {
		data: { tenants: TenantRow[] }
	}

	let { data }: Props = $props()

	// Gets the DARK admin theme because (admin)/admin/+layout.svelte shadowed 'theme'
	// with adminTheme. Same getContext call, nearest ancestor wins.
	const theme = getContext<Theme>('theme')

	const featureKeys: (keyof Features)[] = [
		'advancedReporting',
		'apiAccess',
		'customDomains',
		'prioritySupport',
		'whiteLabeling'
	]

	// Short labels for the feature columns - full key names are too wide for the table
	const featureLabels: Record<keyof Features, string> = {
		advancedReporting: 'Reports',
		apiAccess: 'API',
		customDomains: 'Domains',
		prioritySupport: 'Support',
		whiteLabeling: 'White-label'
	}
</script>

<svelte:head>
	<title>Admin Panel</title>
</svelte:head>

<div class="panel">
	<div class="page-header">
		<h1 style:color={theme.colors.text}>All Tenants</h1>
		<p style:color={theme.colors.textMuted}>All customer tenants on the platform.</p>
	</div>

	<div class="tenant-table" style:border-color={theme.colors.border}>
		<div
			class="table-head"
			style:background={theme.colors.surface}
			style:color={theme.colors.textMuted}
		>
			<span>Tenant</span>
			<span>Plan</span>
			{#each featureKeys as key}
				<span class="feat-col">{featureLabels[key]}</span>
			{/each}
		</div>

		{#each data.tenants as tenant}
			<div
				class="table-row"
				style:border-top-color={theme.colors.border}
				style:--hover-bg={theme.colors.surface}
			>
				<div class="tenant-info">
					<div class="tenant-name" style:color={theme.colors.text}>{tenant.name}</div>
					<div class="tenant-slug" style:color={theme.colors.textMuted}>
						<code>{tenant.slug}.localhost</code>
					</div>
				</div>
				<div>
					<span
						class="plan-chip"
						class:enterprise={tenant.plan === 'enterprise'}
						class:pro={tenant.plan === 'pro'}
						class:free={tenant.plan === 'free'}
						style:background={tenant.plan === 'enterprise'
							? theme.colors.primary
							: tenant.plan === 'pro'
								? theme.colors.accent
								: theme.colors.border}
					>
						{tenant.plan}
					</span>
				</div>
				{#each featureKeys as key}
					<div class="feat-cell" style:color={theme.colors.textMuted}>
						{#if tenant.features[key]}
							<!-- Inline SVG checkmark avoids an extra import -->
							<svg
								xmlns="http://www.w3.org/2000/svg"
								width="14"
								height="14"
								viewBox="0 0 24 24"
								fill="none"
								stroke="currentColor"
								stroke-width="2.5"
								style:color={theme.colors.primary}><polyline points="20 6 9 17 4 12" /></svg
							>
						{:else}
							<span class="feat-dash"></span>
						{/if}
					</div>
				{/each}
			</div>
		{/each}
	</div>
</div>

<style>
	.panel {
		max-width: 960px;
		display: flex;
		flex-direction: column;
		gap: 1.5rem;
	}

	.page-header h1 {
		font-size: 1.75rem;
		font-weight: 800;
		letter-spacing: -0.04em;
		margin-bottom: 0.375rem;
	}

	.page-header p {
		font-size: 0.875rem;
		line-height: 1.5;
	}

	.tenant-table {
		border: 1px solid;
		border-radius: var(--radius-lg);
		overflow: hidden;
		font-size: 0.875rem;
	}

	.table-head,
	.table-row {
		display: grid;
		grid-template-columns: 2fr 1fr repeat(5, 1fr);
		align-items: center;
		padding: 0.625rem 1rem;
		gap: 0.5rem;
	}

	.table-head {
		font-size: 0.6875rem;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.06em;
	}

	.table-row {
		border-top: 1px solid;
		transition: background 0.15s;
	}

	.table-row:hover {
		background: var(--hover-bg);
	}

	.tenant-info {
		display: flex;
		flex-direction: column;
		gap: 0.125rem;
	}
	.tenant-name {
		font-weight: 600;
		font-size: 0.875rem;
	}
	.tenant-slug {
		font-size: 0.75rem;
	}

	.plan-chip {
		display: inline-flex;
		align-items: center;
		font-size: 0.625rem;
		font-weight: 700;
		color: white;
		padding: 0.2rem 0.5rem;
		border-radius: 999px;
		text-transform: uppercase;
		letter-spacing: 0.05em;
	}

	.feat-col {
		font-size: 0.6875rem;
		text-align: center;
	}

	.feat-cell {
		display: flex;
		justify-content: center;
		align-items: center;
	}

	.feat-dash {
		font-size: 0.75rem;
		opacity: 0.4;
	}
</style>

Step 16: Members Page

While the superadmin sees /admin/panel with all customer tenants, a tenant owner or admin lands at /admin/members — their own tenant’s membership list plus a summary of which features their plan includes. The server load function redirects superadmins away so the page always reflects a single tenant.

// src/routes/(admin)/admin/members/+page.server.ts

import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
import { db } from '$lib/server/db'

export const load: PageServerLoad = async ({ locals }) => {
	const { session, tenant } = locals

	// Superadmins see all tenants at /admin/panel, not a single tenant’s members
	if (session?.role === 'superadmin') {
		redirect(303, '/admin/panel')
	}

	const members = db.users.forTenant(tenant!.id)
	const features = db.features.forTenant(tenant!.id)

	return {
		tenant: {
			name: tenant!.name,
			slug: tenant!.slug,
			plan: tenant!.plan,
			features
		},
		members: members.map((u) => ({
			id: u.id,
			name: u.name,
			email: u.email,
			role: u.role
		}))
	}
}

The feature flags are loaded here too so the page can render a plan summary card — giving admins a clear picture of what their tenant has access to, in the same admin UI where they manage their team.

<!-- src/routes/(admin)/admin/members/+page.svelte -->
<script lang="ts">
	import { getContext } from 'svelte'
	import type { Theme } from '$lib/types/context'
	import type { Features } from '$lib/types/context'

	interface Member {
		id: string
		name: string
		email: string
		role: 'member' | 'admin' | 'owner'
	}

	interface Props {
		data: {
			tenant: {
				name: string
				slug: string
				plan: 'free' | 'pro' | 'enterprise'
				features: Features
			}
			members: Member[]
		}
	}

	let { data }: Props = $props()

	// Gets the DARK admin theme because (admin)/admin/+layout.svelte shadowed
	// 'theme' with adminTheme. The brand colors (primary, accent) are preserved
	// from the tenant theme even in dark mode — see the admin layout section.
	const theme = getContext<Theme>('theme')

	const featureKeys: (keyof Features)[] = [
		'advancedReporting',
		'apiAccess',
		'customDomains',
		'prioritySupport',
		'whiteLabeling'
	]

	const featureLabels: Record<keyof Features, string> = {
		advancedReporting: 'Reports',
		apiAccess: 'API',
		customDomains: 'Domains',
		prioritySupport: 'Support',
		whiteLabeling: 'White-label'
	}
</script>

<svelte:head>
	<title>Members — {data.tenant.name}</title>
</svelte:head>

<div class="panel">
	<div class="page-header">
		<h1 style:color={theme.colors.text}>{data.tenant.name}</h1>
		<p style:color={theme.colors.textMuted}>Members and plan details for your tenant.</p>
	</div>

	<!-- Plan + features card -->
	<div
		class="plan-card"
		style:background={theme.colors.surface}
		style:border-color={theme.colors.border}
	>
		<div class="plan-row">
			<span style:color={theme.colors.textMuted}>Plan</span>
			<span
				class="plan-chip"
				class:enterprise={data.tenant.plan === 'enterprise'}
				class:pro={data.tenant.plan === 'pro'}
				class:free={data.tenant.plan === 'free'}
				style:background={data.tenant.plan === 'enterprise'
					? theme.colors.primary
					: data.tenant.plan === 'pro'
						? theme.colors.accent
						: theme.colors.border}
			>
				{data.tenant.plan}
			</span>
		</div>
		<div class="features-row" style:border-top-color={theme.colors.primary}>
			{#each featureKeys as key}
				<div class="feature-pill" style:color={theme.colors.textMuted}>
					{#if data.tenant.features[key]}
						<svg
							xmlns="http://www.w3.org/2000/svg"
							width="12"
							height="12"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2.5"
							style:color={theme.colors.primary}><polyline points="20 6 9 17 4 12" /></svg
						>
					{:else}
						<svg
							xmlns="http://www.w3.org/2000/svg"
							width="12"
							height="12"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2.5"
							style:color={theme.colors.border}
							><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg
						>
					{/if}
					<span
						style:color={data.tenant.features[key] ? theme.colors.text : theme.colors.textMuted}
					>
						{featureLabels[key]}
					</span>
				</div>
			{/each}
		</div>
	</div>

	<!-- Members table -->
	<div class="members-table" style:border-color={theme.colors.border}>
		<div
			class="table-head"
			style:background={theme.colors.surface}
			style:color={theme.colors.textMuted}
		>
			<span>Name</span>
			<span>Email</span>
			<span>Role</span>
		</div>

		{#each data.members as member}
			<div
				class="table-row"
				style:border-top-color={theme.colors.border}
				style:--hover-bg={theme.colors.surface}
			>
				<div class="member-name" style:color={theme.colors.text}>{member.name}</div>
				<div class="member-email" style:color={theme.colors.textMuted}>{member.email}</div>
				<span
					class="role-chip"
					class:role-owner={member.role === 'owner'}
					class:role-admin={member.role === 'admin'}
					class:role-member={member.role === 'member'}
					style:background={member.role === 'owner'
						? theme.colors.primary
						: member.role === 'admin'
							? theme.colors.accent
							: theme.colors.border}
				>
					{member.role}
				</span>
			</div>
		{/each}
	</div>
</div>

<style>
	.panel {
		max-width: 720px;
		display: flex;
		flex-direction: column;
		gap: 1.5rem;
	}

	.page-header h1 {
		font-size: 1.75rem;
		font-weight: 800;
		letter-spacing: -0.04em;
		margin-bottom: 0.375rem;
	}

	.page-header p {
		font-size: 0.875rem;
		line-height: 1.5;
	}

	/* Plan card */
	.plan-card {
		border: 1px solid;
		border-radius: var(--radius-lg);
		overflow: hidden;
	}

	.plan-row {
		display: flex;
		align-items: center;
		justify-content: space-between;
		padding: 0.875rem 1rem;
		font-size: 0.875rem;
	}

	.plan-chip,
	.role-chip {
		display: inline-flex;
		align-items: center;
		font-size: 0.625rem;
		font-weight: 700;
		color: white;
		padding: 0.2rem 0.5rem;
		border-radius: 999px;
		text-transform: uppercase;
		letter-spacing: 0.05em;
	}

	.features-row {
		display: flex;
		flex-wrap: wrap;
		gap: 0.75rem;
		padding: 0.75rem 1rem;
		border-top: 1px solid;
	}

	.feature-pill {
		display: flex;
		align-items: center;
		gap: 0.3rem;
		font-size: 0.75rem;
	}

	/* Members table */
	.members-table {
		border: 1px solid;
		border-radius: var(--radius-lg);
		overflow: hidden;
		font-size: 0.875rem;
	}

	.table-head,
	.table-row {
		display: grid;
		grid-template-columns: 1.5fr 2fr 0.75fr;
		align-items: center;
		padding: 0.625rem 1rem;
		gap: 0.5rem;
	}

	.table-head {
		font-size: 0.6875rem;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.06em;
	}

	.table-row {
		border-top: 1px solid;
		transition: background 0.15s;
	}

	.table-row:hover {
		background: var(--hover-bg);
	}

	.member-name {
		font-weight: 600;
		font-size: 0.875rem;
	}

	.member-email {
		font-size: 0.8125rem;
	}
</style>

The plan card makes the tenant admin’s feature entitlements visible at a glance, coloured with the same tenant brand primary colour that flows through the admin dark theme. A free tenant’s features row will be mostly muted crosses; an enterprise tenant like Acme gets solid primary-coloured checkmarks across the board.


// src/routes/logout/+page.server.ts
import { redirect } from '@sveltejs/kit'
import type { Actions } from './$types'
import { db } from '$lib/server/db'

export const actions: Actions = {
	default: async ({ cookies }) => {
		const token = cookies.get('session')
		if (token) {
			db.sessions.deleteByToken(token)
			cookies.delete('session', { path: '/' })
		}
		redirect(303, '/login')
	}
}

The AppHeader already has a <form method="POST" action="/logout"> button, no additional page component needed.



The Full Context Flow

Here’s what happens from URL to component, concretely:

Browser: GET acme.localhost/dashboard

        hooks.server.ts
          ├── hostname = 'acme.localhost' → slug = 'acme'
          ├── db.tenants.findBySlug('acme') → Acme Corp (enterprise)
          ├── event.locals.tenant = { id: 'tenant_acme', name: 'Acme Corp', … }
          ├── cookies.get('session') → validate → db.users.findById(userId)
          └── event.locals.session = { name: 'Alice Admin', role: 'admin', … }

        +layout.server.ts (root)
          └── returns { appConfig, session: { name, role, … }, tenantSlug: 'acme' }

        (app)/+layout.server.ts
          ├── locals.tenant already set by hook
          ├── db.features.forTenant('tenant_acme')
          └── returns { tenant: { branding: { primaryColor: '#ff6452', … } }, features: { all true } }

        Component tree rendering:
          +layout.svelte (root)
            setContext('theme', { colors: { primary: '#ff6452', … } })  ← default
            setContext('session', { name: 'Alice', role: 'admin' })

          (app)/+layout.svelte
            getContext('theme')  → gets the root default theme
            tenantTheme = { colors: { primary: '#ff6452', accent: '#ff7961', … } }
            setContext('theme', tenantTheme)   ← SHADOWS root theme
            setContext('tenant', { name: 'Acme Corp', … })
            setContext('features', { advancedReporting: true, … })

          dashboard/+page.svelte
            getContext('theme')    → gets tenantTheme (orange-red, enterprise)
            getContext('tenant')   → gets Acme Corp
            getContext('features') → all enabled (enterprise)
            AppHeader, AppSidebar, StatsCard all call getContext('theme')
            → all get tenantTheme automatically, zero prop passing

Switch to globex.localhost/dashboard:
  hook → slug = 'globex' → Globex Inc (sky blue, pro plan)
  (app)/layout sets tenant theme with primary: '#0ea5e9'
  Every component gets sky blue, same component files, different context values.

Navigate to acme.localhost/admin/panel (alice, role=admin):
  (admin)/+layout.server.ts → role check passes
  (admin)/+layout.svelte
    getContext('theme')  → gets current tenant theme (indigo)
    adminTheme = { mode: 'dark', colors: { background: '#09090b', primary: '#ff6452' } }
    setContext('theme', adminTheme)  ← SHADOWS tenant theme
  AdminNav + panel page
    getContext('theme') → dark admin theme with tenant brand colors preserved

Context Shadowing: The Core Concept Illustrated

The theme key is set three times across the layout hierarchy:

Root layout       setContext('theme', defaultTheme)      ← orange-red, light
  (app) layout    setContext('theme', tenantTheme)        ← tenant brand, light  [shadows root]
    (admin) layout  setContext('theme', adminTheme)       ← tenant brand, dark   [shadows app]

Each setContext call creates a new binding in that component’s context scope. Descendant components that call getContext('theme') always receive the value from the nearest ancestor that called setContext with that key. The root value is never destroyed, it’s simply unreachable from inside the shadowing subtree.

This is why context shadowing is powerful for SaaS theming:

Component in (app)/dashboard   → tenantTheme  (light, brand colors)
Component in (admin)/panel     → adminTheme   (dark, same brand colors)
Hypothetical component at /    → defaultTheme (light, generic)

Zero prop drilling. Zero global state. Each layer independently composable.


Type-Safe Context Access

The getContext<T>() generic parameter is checked at compile time:

// ✅ TypeScript knows what you get
const theme = getContext<Theme>('theme')
const tenantCtx = getContext<{ readonly current: Tenant }>('tenant')
const featuresCtx = getContext<{ readonly current: Features }>('features')

// Unwrap with $derived so Svelte tracks the dependency
const tenant = $derived(tenantCtx.current)
const features = $derived(featuresCtx.current)

theme.colors.primary // ✅ string - TypeScript knows
tenant.branding.logoIcon // ✅ TenantIconName - from Tenant type
features.apiAccess // ✅ boolean - from Features type
features.nonExistent // ❌ TypeScript error - 'nonExistent' doesn't exist on Features

For even tighter safety, use the helper functions from $lib/context-helpers (created in Step 1b) which centralise the type annotations and the getter-object pattern in one place. Any rename or type change surfaces as a TypeScript error across every consumer immediately.


Common Mistakes

Mistake 1: Computing feature flags client-side

// ❌ WRONG - client can modify plan, bypass gates
const features = {
	advancedReporting: tenant.plan === 'enterprise'
}
setContext('features', features)

Features must come from the server load function, which reads from the database. The client receives the resolved boolean, it never sees the logic that produced it.

// ✅ CORRECT - resolved server-side, forwarded to context
// (app)/+layout.server.ts
const features = db.features.forTenant(tenant.id)
return { features }

// (app)/+layout.svelte
setContext<{ readonly current: Features }>('features', {
	get current() {
		return data.features
	}
})

Mistake 2: Cross-tenant session leakage

// ❌ WRONG - only checks session exists, not which tenant it belongs to
if (sessionToken) {
	const session = db.sessions.findByToken(sessionToken)
	event.locals.session = session ? toSession(db.users.findById(session.userId)) : null
}

If Alice from Acme visits globex.localhost, she’d be logged in as Alice on Globex’s data. Always verify tenant membership at session validation:

// ✅ CORRECT - cross-tenant check in the hook
if (rawUser && rawUser.tenantId === rawTenant.id) {
	event.locals.session = toSession(rawUser)
} else {
	// Cross-tenant - destroy the session
	db.sessions.deleteByToken(sessionToken)
	event.locals.session = null
}

Mistake 3: Calling setContext after component initialization

<!-- ❌ WRONG - setContext must be called during component init, not in effects -->
<script>
	$effect(() => {
		setContext('theme', newTheme) // Too late - context is frozen after init
	})
</script>
<!-- ✅ CORRECT - call at top level of script -->
<script>
	setContext('theme', tenantTheme)
</script>

Mistake 4: Using context across unrelated subtrees

<!-- ❌ WRONG - context only flows down, not sideways -->
<!-- ComponentA sets context -->
<!-- ComponentB (sibling) tries to read it - gets undefined -->

Context flows strictly from parent to children. If two sibling components need shared state, lift the context to their common ancestor layout.

Mistake 5: Mutating the context object directly

<!-- ❌ WRONG - mutating bypasses reactivity -->
<script>
	const features = getContext('features')
	features.apiAccess = true // Silent mutation - other components won't re-render
</script>

If feature flags need to change (e.g. after an upgrade), call invalidateAll() to re-run the server load functions and rebuild the context from fresh DB data.


Performance Notes

Feature flag lookup is O(n × m) in the current implementation (n flags × m tenants). For production with hundreds of tenants and dozens of flags, index the flags table by (tenantId, key) and cache resolved flags in memory with a short TTL:

// Production pattern - simple in-process cache
const flagCache = new Map<string, { flags: Features; expiresAt: number }>()

function cachedFlagsForTenant(tenantId: string): Features {
	const cached = flagCache.get(tenantId)
	if (cached && cached.expiresAt > Date.now()) return cached.flags

	const flags = db.features.forTenant(tenantId)
	flagCache.set(tenantId, { flags, expiresAt: Date.now() + 60_000 }) // 1 min TTL
	return flags
}

Context objects don’t need to be reactive themselves. The values inside context (theme, features) are passed by reference. If you need them to update after SvelteKit data changes, let the layout re-run via invalidateAll(), the layout will reconstruct the context from fresh data props.

setContext / getContext are synchronous and allocation-free at read time. The lookup is a simple Map key access, no proxying, no subscriptions. Don’t hesitate to call getContext inside components; it’s not expensive.


What’s Next

This demo covers the essential multi-tenant patterns. In production you’d extend with:

  • Subdomain wildcard routing in your hosting provider (Vercel/Fly.io) so *.yourapp.com all reach the same SvelteKit instance
  • Real feature flag service like Unleash or LaunchDarkly, the (app)/+layout.server.ts is the integration point; swap db.features.forTenant() for the service call
  • Tenant-scoped database schemas or row-level security (RLS) in Postgres, the locals.tenant.id is the isolation key for every query
  • SSO per tenant - locals.tenant.id drives which SAML/OIDC config to use in your auth hook

The context architecture shown here scales directly. The layers (root → app → admin) and the shadowing pattern (setContext / getContext) don’t change, only the data sources behind them.


Key Takeaways

Server determines the tenant, context distributes it. The hook resolves the tenant from the subdomain before any load function runs. Context just makes that data available deep in the tree without prop drilling.

Feature flags belong on the server. Never compute plan === 'enterprise' in layouts. Resolve flags from a DB or service in the load function and pass the resolved booleans to context. The client never sees the entitlement logic.

Context shadowing is the multi-tenant theming primitive. The same 'theme' key is set three times, default, tenant-branded, admin dark, and each subtree reads the nearest ancestor’s value automatically. No conditional logic in components, no global theme store.

Admin routes need proper separation. A route group (admin)/* with its own +layout.server.ts that calls error(403, …) is the right pattern. Hiding admin buttons in the regular dashboard is not access control.

Tenant isolation requires cross-checking at the session layer. A valid session from Tenant A must be rejected on Tenant B’s subdomain. This check happens in the hook, before any data loads.


See Also

Official Documentation

Track complete
You've finished Svelte 5 Reference.