This page explains how tenant routing works via subdomains, how the schoolId is resolved on the server, and how to scope all database reads/writes per tenant. It also includes local‑dev tips without real subdomains.
khartoum.hogwarts.app.schoolId.?x-school=khartoum to any page, e.g. /dashboard?x-school=khartoum.NEXT_PUBLIC_ROOT_DOMAIN=localhost and visit http://khartoum.localhost:3000/dashboard.https://khartoum.hogwarts.app/dashboard.ADMIN) is set. See “Set my role for testing”.hogwarts.app (set via NEXT_PUBLIC_ROOT_DOMAIN).School model as domain (e.g., khartoum). Full host example: khartoum.hogwarts.app.schoolId. Every query/mutation must include { schoolId }.// prisma/models/school.prisma
model School {
id String @id @default(cuid())
name String
domain String @unique // e.g. "khartoum" for khartoum.hogwarts.app
// ...other fields
}// src/middleware.ts (excerpt)
const host = nextUrl.hostname
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN // e.g. "hogwarts.app"
let subdomain: string | null = null
const devDomainParam = nextUrl.searchParams.get("x-school")
if (devDomainParam) {
subdomain = devDomainParam
} else if (rootDomain && host.endsWith("." + rootDomain)) {
subdomain = host.slice(0, -(rootDomain.length + 1)) || null
}
if (subdomain) {
const requestHeaders = new Headers(req.headers)
requestHeaders.set("x-subdomain", subdomain)
return Response.next({ request: { headers: requestHeaders } })
}schoolId using the injected header (or impersonation cookie, or session fallback).// src/components/platform/operator/lib/tenant.ts (excerpt)
export async function getTenantContext() {
const session = await auth()
const cookieStore = await cookies()
const hdrs = await headers()
const impersonatedSchoolId = cookieStore.get("impersonate_schoolId")?.value ?? null
let headerSchoolId: string | null = null
const subdomain = hdrs.get("x-subdomain")
if (subdomain) {
const school = await db.school.findUnique({ where: { domain: subdomain } })
headerSchoolId = school?.id ?? null
}
const schoolId = impersonatedSchoolId ?? headerSchoolId ?? session?.user?.schoolId ?? null
const role = (session?.user?.role as UserRole | undefined) ?? null
const isPlatformAdmin = role === "DEVELOPER"
const requestId = null
return { schoolId, requestId, role, isPlatformAdmin }
}{ schoolId }.// Example usage in a server action
import { getTenantContext } from "@/components/platform/operator/lib/tenant"
import { db } from "@/lib/db"
export async function listStudents() {
const { schoolId } = await getTenantContext()
if (!schoolId) throw new Error("Missing tenant context")
return db.student.findMany({ where: { schoolId } })
}Next.js middleware can’t safely perform dynamic DB queries in all environments. We extract the subdomain in middleware, add it as x-subdomain, and resolve to schoolId later on the server where DB access is allowed.
You have two convenient options while developing on localhost:
?x-school=<domain> to any URL to simulate a tenant. Example: /dashboard?x-school=khartoum.?domain=<domain>.
GET /api/terms?domain=khartoumGET /api/timetable?domain=khartoum&weekOffset=0Seeded demo domains: khartoum, omdurman, portsudan, wadmadani.
/operator/tenant-debug in the app to see the current tenant and live counts for students/teachers/classes.?x-school=<domain> and watch the counts change instantly.khartoum.hogwarts.app/operator/tenant-debug).?x-school=<domain> in dev or switch to the correct subdomain in prod.Pick one of these:
Easiest (URL param): add ?x-school=<domain> to the page you’re testing.
/dashboard?x-school=khartoum.Correct (user is tied to a school): set your user’s schoolId to match a school.
pnpm dlx prisma studioSchool table, copy the id of the row where domain = "khartoum".User table, set your schoolId to that id and save.Operator (DEVELOPER only): start impersonation which sets a cookie overriding the tenant.
startImpersonation(schoolId) sets an impersonate_schoolId cookie for ~30 minutes.Set your root domain to enable subdomain parsing in middleware.
NEXT_PUBLIC_ROOT_DOMAIN=hogwarts.appIf you deploy to a different host, update this variable accordingly (e.g., ed.databayt.org).
In local development, to use subdomains like khartoum.localhost:3000, set:
NEXT_PUBLIC_ROOT_DOMAIN=localhostPages and actions are often role‑gated (e.g., Admin‑only). Make your test user an ADMIN or DEVELOPER:
Using Prisma Studio:
pnpm dlx prisma studioUser tablerole to one of: DEVELOPER, ADMIN, TEACHER, STUDENT, GUARDIAN, ACCOUNTANT, STAFF, USERUsing SQL (example):
-- Make the user a developer
UPDATE users SET role = 'DEVELOPER' WHERE email = 'me@example.com';
-- Tie the user to the khartoum school
UPDATE users
SET schoolId = (SELECT id FROM schools WHERE domain = 'khartoum')
WHERE email = 'me@example.com';Auth attaches schoolId to the JWT/session. If a request lacks x-subdomain and impersonation cookie, getTenantContext() falls back to session.user.schoolId.
// src/auth.ts (excerpt – JWT callback)
;(token as unknown as { schoolId?: string | null }).schoolId = existingUser.schoolId ?? nullOperator tools can set an impersonate_schoolId cookie to override the current tenant. This is resolved first in getTenantContext().
{ schoolId }.schoolId where applicable.// prisma/README.md (principle)
// ✅ Correct – Always include schoolId
await prisma.student.findMany({ where: { schoolId } })/docs (public) to ensure middleware doesn’t block docs./(platform)/operator/tenant-debug.?x-school=<domain>); in prod, move between subdomains./students and /teachers with the same ?x-school=<domain> in dev or under the subdomain in prod.NEXT_PUBLIC_ROOT_DOMAIN is set correctly and a School row exists with domain matching the subdomain.?x-school=<domain> or the public API ?domain=<domain> fallbacks.schoolId or specify x-school.On This Page
Domain & Subdomain — Multi‑tenant guideExplain it like I'm five (ELI5)TL;DR — Quick test (dev + prod)ConceptsData modelRequest flow (overview)Why a header instead of DB calls in middleware?Local development without real subdomainsHands‑on: interactive debug pageSimple: set my school for testingEnvironmentSet my role for testingAuth session and tenant fallbackImpersonation (operator/dev only)Guardrails (must‑do)Verifying your setupTroubleshooting