Domain & Subdomain — Multi‑tenant guide
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.
Explain it like I'm five (ELI5)
- Think of the website like a big city (the root domain).
- Each school is a different house on its own street (the subdomain), like
khartoum.hogwarts.app
. - When you walk into a house, you only see that family’s stuff. In our app, when you go to a school’s subdomain, you only see that school’s data.
- The “key” we use to make sure we only look at the right family’s stuff is called
schoolId
.
TL;DR — Quick test without subdomains
- Add
?x-school=khartoum
to any URL you’re testing, e.g./dashboard?x-school=khartoum
. - Make sure your user has the right role (e.g.,
ADMIN
) if the page requires it (see “Set my role for testing” below). - All server code will treat the request as if you visited
khartoum.hogwarts.app
.
Concepts
- Root domain: Your main host, e.g.,
hogwarts.app
(set viaNEXT_PUBLIC_ROOT_DOMAIN
). - Subdomain (tenant slug): The school’s unique domain slug stored on the
School
model asdomain
(e.g.,khartoum
). Full host example:khartoum.hogwarts.app
. - Tenant identity: All data is partitioned by
schoolId
. Every query/mutation must include{ schoolId }
.
Data model
// 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
}
Request flow (overview)
- A user visits a host. In middleware, we extract the subdomain and propagate it in a header.
// 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 } })
}
- On the server, we resolve
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 }
}
- All server actions and DB access must include
{ 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 } })
}
Why a header instead of DB calls in middleware?
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.
Local development without real subdomains
You have two convenient options while developing on localhost
:
- Add
?x-school=<domain>
to any URL to simulate a tenant. Example:/dashboard?x-school=khartoum
. - For public, unauthenticated APIs that support fallback, pass
?domain=<domain>
.- See Timetable docs for examples:
- Terms:
GET /api/terms?domain=khartoum
- Timetable:
GET /api/timetable?domain=khartoum&weekOffset=0
- Terms:
- See Timetable docs for examples:
Seeded demo domains: khartoum
, omdurman
, portsudan
, wadmadani
.
Simple: set my school for testing
Pick one of these:
-
Easiest (URL param): add
?x-school=<domain>
to the page you’re testing.- Example:
/dashboard?x-school=khartoum
.
- Example:
-
Correct (user is tied to a school): set your user’s
schoolId
to match a school.- Using Prisma Studio:
- Run:
pnpm dlx prisma studio
- Open
School
table, copy theid
of the row wheredomain = "khartoum"
. - Open
User
table, set yourschoolId
to thatid
and save.
- Run:
- Using Prisma Studio:
-
Operator (DEVELOPER only): start impersonation which sets a cookie overriding the tenant.
- The action
startImpersonation(schoolId)
sets animpersonate_schoolId
cookie for ~30 minutes. - A banner appears with “Stop impersonation” to clear it.
- The action
Environment
Set your root domain to enable subdomain parsing in middleware.
NEXT_PUBLIC_ROOT_DOMAIN=hogwarts.app
If you deploy to a different host, update this variable accordingly (e.g., ed.databayt.org
).
Set my role for testing
Pages and actions are often role‑gated (e.g., Admin‑only). Make your test user an ADMIN
or DEVELOPER
:
-
Using Prisma Studio:
pnpm dlx prisma studio
- Open
User
table - Set
role
to one of:DEVELOPER
,ADMIN
,TEACHER
,STUDENT
,GUARDIAN
,ACCOUNTANT
,STAFF
,USER
- Save
-
Using 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 session and tenant fallback
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 ?? null
Impersonation (operator/dev only)
Operator tools can set an impersonate_schoolId
cookie to override the current tenant. This is resolved first in getTenantContext()
.
Guardrails (must‑do)
- Always scope Prisma queries and mutations with
{ schoolId }
. - Keep unique constraints scoped by
schoolId
where applicable. - Never rely on client state for tenant identity; the server is the source of truth.
// prisma/README.md (principle)
// ✅ Correct – Always include schoolId
await prisma.student.findMany({ where: { schoolId } })
Verifying your setup
- Visit
/docs
(public) to ensure middleware doesn’t block docs. - Visit a platform page with a dev param:
/dashboard?x-school=khartoum
. - Trigger a server action that reads from the DB; verify data is tenant‑scoped.
- Optional: use a subdomain in a deployed preview, e.g.,
khartoum.hogwarts.app
.
Troubleshooting
- Missing tenant: ensure
NEXT_PUBLIC_ROOT_DOMAIN
is set correctly and aSchool
row exists withdomain
matching the subdomain. - Local dev without subdomain: use
?x-school=<domain>
or the public API?domain=<domain>
fallbacks. - Session fallback is null: sign in a user that has
schoolId
or specifyx-school
.