0%
balqalam Logo
balqalam
FeaturesCommunityPricingDocumentation
Login
  • 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
  • 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
Sales & GTM
  • Marketing Brief
  • Sales
  • Go-to-market
  • Marketing
  • Admission — Feature Spotlight
  • Pilot Program
  • Leads
  • Proposal
  • Outreach Templates
  • Case Study
  • Competitors
  • Landing-page teardown
  • Competitor FAQ
  • Business model
  • Shared economy
  • Traction
Fundraising & Ecosystem
  • Get Support
  • Investor Deck
  • Data Room
  • Investors
  • Accelerators
  • Incubators
  • Grants
  • Sponsors
  • Partners
  • Competitions & Hackathons
  • Universities & Training Centers

Multi-Tenancy

PreviousNext

Subdomain routing, tenant context resolution, and database isolation via schoolId scoping.

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:

  1. Edge middleware skips static files, detects locale and subdomain, performs lightweight JWT decode for RBAC, and rewrites the URL to /en/s/school/dashboard. The x-subdomain header is set for downstream consumers.
  2. Tenant context resolves the canonical schoolId with priority: impersonation cookie → x-subdomain header → session JWT.
  3. Server component / action uses getTenantContext() and includes schoolId in every database query.
  4. Database enforces tenant safety through @@unique constraints and @@index([schoolId]) per business model.

Entry points

Entry pointDomainApp pathAuthPurpose
SaaS Marketinged.databayt.org/(saas-marketing)PublicLanding, pricing, docs
SaaS Dashboarded.databayt.org/(saas-dashboard)DEVELOPER onlyPlatform admin
School Marketing{school}.databayt.org/s/[subdomain]/(school-marketing)PublicSchool public pages
School Dashboard{school}.databayt.org/s/[subdomain]/(school-dashboard)AuthenticatedSchool 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:

  1. schoolId field on every business model.
  2. @@unique constraints scoped by schoolId so cross-tenant collisions are impossible.
  3. @@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

LayerTechnologyTTLScope
Tenant resolutionUpstash Redis5 minShared across instances
Tenant resolutionIn-memory Map1 minPer instance (fallback)
Database queriesNone—No caching
ImagesNext.js Image60s minCDN

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

EnvironmentDomain patternExample
Production*.databayt.orgschool.databayt.org
Previewtenant---branch.vercel.appdemo---feature-x.vercel.app
Development*.localhost:3000demo.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
Flow DiagramsOffline

On This Page

Request flowEntry pointsEdge middlewareSubdomain detectionURL rewritingTenant contextDatabase isolationQuery patternsDomain managementCachingDeploymentTestingSee also

Built by Databayt ·

Welcome to balqalam.

A great journey is about to begin.