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 via NEXT_PUBLIC_ROOT_DOMAIN).
  • Subdomain (tenant slug): The school’s unique domain slug stored on the School model as domain (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)

  1. 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 } })
}
  1. 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 }
}
  1. 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

Seeded demo domains: khartoum, omdurman, portsudan, wadmadani.

Simple: set my school for testing

Pick one of these:

  1. Easiest (URL param): add ?x-school=<domain> to the page you’re testing.

    • Example: /dashboard?x-school=khartoum.
  2. Correct (user is tied to a school): set your user’s schoolId to match a school.

    • Using Prisma Studio:
      1. Run: pnpm dlx prisma studio
      2. Open School table, copy the id of the row where domain = "khartoum".
      3. Open User table, set your schoolId to that id and save.
  3. Operator (DEVELOPER only): start impersonation which sets a cookie overriding the tenant.

    • The action startImpersonation(schoolId) sets an impersonate_schoolId cookie for ~30 minutes.
    • A banner appears with “Stop impersonation” to clear it.

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:

    1. pnpm dlx prisma studio
    2. Open User table
    3. Set role to one of: DEVELOPER, ADMIN, TEACHER, STUDENT, GUARDIAN, ACCOUNTANT, STAFF, USER
    4. 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

  1. Visit /docs (public) to ensure middleware doesn’t block docs.
  2. Visit a platform page with a dev param: /dashboard?x-school=khartoum.
  3. Trigger a server action that reads from the DB; verify data is tenant‑scoped.
  4. 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 a School row exists with domain 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 specify x-school.