- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
Hogwarts is a multi-tenant SaaS platform: each school operates on its own subdomain (school.databayt.org) with complete data isolation via schoolId scoping. This page covers the path from edge middleware to database query.
For authentication, OAuth, and RBAC see authentication, oauth, and authorization.
Request flow
A request to school.databayt.org/dashboard traverses four layers:
- Edge middleware skips static files, detects locale and subdomain, performs lightweight JWT decode for RBAC, and rewrites the URL to
/en/s/school/dashboard. Thex-subdomainheader is set for downstream consumers. - Tenant context resolves the canonical
schoolIdwith priority: impersonation cookie →x-subdomainheader → session JWT. - Server component / action uses
getTenantContext()and includesschoolIdin every database query. - Database enforces tenant safety through
@@uniqueconstraints and@@index([schoolId])per business model.
Entry points
| Entry point | Domain | App path | Auth | Purpose |
|---|---|---|---|---|
| SaaS Marketing | ed.databayt.org | /(saas-marketing) | Public | Landing, pricing, docs |
| SaaS Dashboard | ed.databayt.org | /(saas-dashboard) | DEVELOPER only | Platform admin |
| School Marketing | {school}.databayt.org | /s/[subdomain]/(school-marketing) | Public | School public pages |
| School Dashboard | {school}.databayt.org | /s/[subdomain]/(school-dashboard) | Authenticated | School management |
Edge middleware
The edge proxy runs on every non-static request and stays under the Edge size limit by avoiding heavy imports. Processing order: skip static and API paths, detect locale (NEXT_LOCALE cookie → Accept-Language → default ar), detect subdomain, classify the route, perform a lightweight JWT decode for RBAC pre-checks, then rewrite the URL with x-subdomain set for downstream consumers.
JWT role decoding at the edge uses base64 only — full crypto verification happens in server actions via auth(). The JWT is already in an httpOnly secure cookie; the edge decode is for routing only.
Subdomain detection
let subdomain: string | null = null
if (host.endsWith(".databayt.org") && !host.startsWith("ed.")) {
// production: school.databayt.org → "school"
subdomain = host.split(".")[0]
} else if (host.includes("---") && host.endsWith(".vercel.app")) {
// Vercel preview: tenant---branch.vercel.app → "tenant"
subdomain = host.split("---")[0]
} else if (host.includes("localhost") && host.includes(".")) {
// dev: subdomain.localhost:3000 → "subdomain"
const parts = host.split(".")
if (parts.length > 1 && parts[0] !== "www" && parts[0] !== "localhost") {
subdomain = parts[0]
}
}ed.databayt.org is the main domain, not a tenant. The !host.startsWith("ed.") check prevents misclassification.
URL rewriting
Users see clean paths; the server processes tenant-scoped paths.
User sees: school.databayt.org/dashboard
Server sees: school.databayt.org/en/s/school/dashboard
File lives: src/app/[lang]/s/[subdomain]/(school-dashboard)/dashboard/page.tsx
url.pathname = `/${locale}/s/${subdomain}${pathWithoutLocale}`
const response = NextResponse.rewrite(url)
response.headers.set("x-subdomain", subdomain)/login and /join exist globally at /[lang]/(auth)/*, not within the subdomain structure — they aren't rewritten. The matcher excludes /_next/, /api/, and any path with a file extension.
Tenant context
getTenantContext() is the single source of truth for tenant isolation. Every server component and server action should call it.
export async function getTenantContext(): Promise<TenantContext> {
// 1. impersonation cookie (DEVELOPER debugging)
const impersonatedSchoolId =
cookieStore.get("impersonate_schoolId")?.value ?? null
// 2. subdomain from middleware → resolve to schoolId
let headerSchoolId: string | null = null
const subdomain = hdrs.get("x-subdomain")
if (subdomain) {
headerSchoolId = await getSchoolIdFromSubdomain(subdomain)
}
// 3. session schoolId from JWT
const schoolId =
impersonatedSchoolId ?? headerSchoolId ?? session?.user?.schoolId ?? null
return { schoolId, requestId: null, role, isPlatformAdmin }
}type TenantContext = {
schoolId: string | null
requestId: string | null
role: UserRole | null
isPlatformAdmin: boolean
}Subdomain → schoolId lookups use a two-tier cache: Upstash Redis (5 minutes, shared across instances) → in-memory Map (1 minute, max 100 entries, per-instance fallback). Failed lookups are not cached. Redis is optional — without it, the Map tier still serves traffic with cold starts hitting the database.
Limitations: the Map tier is per-instance, so cold starts may hit Redis or the database. Subdomain changes propagate within 5 minutes globally and 1 minute per instance.
Database isolation
Every tenant-scoped model carries schoolId with three guarantees:
schoolIdfield on every business model.@@uniqueconstraints scoped byschoolIdso cross-tenant collisions are impossible.@@index([schoolId])for query performance.
model Student {
id String @id @default(cuid())
schoolId String
school School @relation(fields: [schoolId], references: [id], onDelete: Cascade)
@@unique([schoolId, studentId])
@@unique([schoolId, grNumber])
@@index([schoolId])
}Query patterns
// correct — includes schoolId
await db.student.findMany({
where: { schoolId, yearLevel: "10" },
})
// wrong — missing schoolId, breaks tenant isolation
await db.student.findMany({
where: { yearLevel: "10" },
})schoolId injection is currently manual. Auth tokens, catalog globals, and platform-wide subscription tiers intentionally lack schoolId — see database for the full list.
Domain management
Each school has a unique domain field on the School model — assigned during onboarding.
model School {
id String @id @default(cuid())
domain String @unique
}DnsService validates subdomains: 3 to 63 characters, alphanumeric and hyphens only (RFC 1035), no consecutive hyphens, with reserved words blocked (www, api, admin, mail, dashboard, portal, dev, test, staging, demo, preview, etc.). The DomainRequest model supports custom domain approval with a pending → approved → verified flow; DNS provider integrations (Cloudflare, Route 53, Vercel) are tracked separately. See onboarding for the assignment step.
Caching
| Layer | Technology | TTL | Scope |
|---|---|---|---|
| Tenant resolution | Upstash Redis | 5 min | Shared across instances |
| Tenant resolution | In-memory Map | 1 min | Per instance (fallback) |
| Database queries | None | — | No caching |
| Images | Next.js Image | 60s min | CDN |
Two-tier cache: Redis is checked first, then the Map; missing entries fall through to the database. The Map holds a maximum of 100 entries.
Deployment
| Environment | Domain pattern | Example |
|---|---|---|
| Production | *.databayt.org | school.databayt.org |
| Preview | tenant---branch.vercel.app | demo---feature-x.vercel.app |
| Development | *.localhost:3000 | demo.localhost:3000 |
Production requires wildcard DNS *.databayt.org pointing to Vercel, an SSL certificate covering *.databayt.org, and NEXT_PUBLIC_ROOT_DOMAIN=databayt.org.
Testing
Multi-tenant safety is covered by Playwright atomic tests under /tests. Story groups exercise: SaaS marketing public access, login UI, protected route redirects, RBAC restrictions per role, tenant isolation, locale switching, fresh user flow, cross-subdomain SSO. Run pnpm test:e2e:multi-tenant to execute the suite.
See also
- Authentication — JWT, sessions, redirect priority chain
- OAuth — providers, multi-tenant adapter, callback preservation, cookies
- Authorization — feature-level RBAC permission checks
- Database — Prisma models, schoolId scoping, models without
schoolId - Vercel platforms starter kit
- NextAuth.js documentation