Multi-tenant learning platform with per-organisation modules.
- Role
- Lead full-stack & infra developer
- Period
- 2024 — aujourd’hui

1 100+
commits
multi
tenant, per-organisation modules
R2
Postgres backups
2
platform migrations
Context
A platform that went through several generations (Appwrite, Directus, Logto, white-label) and needed consolidation.
The challenge
Unify the codebase, keep organisations isolated and cut hosting costs sharply.
- 01Dev
NestJS + Next.js monorepo
NestJS 11 / Prisma 6 API, Next.js 16 / MUI / React Query front, Docusaurus docs.
- 02Dev
Multi-tenant
Modules and routes toggled per organisation, role-based access control.
- 03Infra
Off managed services
Moved from Neon to self-hosted Postgres, backed up to Cloudflare R2.
- 04Infra
Coolify to Dokploy
Standardised deployment on the same PaaS as my other products.
Architecture
Stack
- NestJS
- Prisma
- PostgreSQL
- Next.js
- React
- Stripe
- DDokploy
- Cloudflare (DNS, R2)
- Docker
Results
- ✦Lower infrastructure costs
- ✦One monorepo instead of several generations
The story
The screenshots below come from the monorepo running locally, on a database filled by the backend seed: three fictional organisations (TechCorp Academy, EduSoft Learning, PM Academy) and their ADMIN, LEARNER, MENTOR and PARTNER accounts. Only the marketing page comes from the public site.
How it works
1. One organisation, one domain

Civinat serves several organisations from a single codebase. The domain acts as the identifier: civinat.com for the main organisation, a *.civinat.com subdomain for the others. Traefik routes the whole wildcard to the same Next.js container, and the backend decides which organisation a request belongs to.
2. The admin dashboard

An ADMIN lands on a dashboard that only shows their organisation. The front end sends the domain in an x-tenant-domain header; on the API side, a middleware looks up the organisation and attaches it to the request. Roles sit on top: ADMIN manages one organisation, SUPER_ADMIN goes through a dedicated domain and sees all of them.
3. The organisation’s content

Modules, sub-modules, lessons and questions belong to one organisation. CSV import and export exist because organisations come with their own material, and it had to be loaded without retyping everything.
4. Toggleable routes

This is the feature that makes the platform truly multi-tenant: every route of the learner journey (dashboard, modules, coach, podcast, news…) has a switch. A disabled route disappears from the sidebar, and the Next.js middleware redirects direct access to /access-denied. An organisation that does not sell coaching does not have to show an empty tab.
5. PWA and billing, per organisation

The PWA manifest is not a static file: manifest.webmanifest is a Next.js route that reads the domain and asks the backend for the organisation’s name, icons (stored on R2) and theme colours. Each organisation installs on a phone with its own identity.

Billing follows the same logic: each organisation has its own Stripe settings, its offers (the paths and quiz types a subscription unlocks) and its prices.
6. The coach area


A MENTOR has their own area. A wizard guides them on first login, then they set their weekly slots. Learners can only book within those slots; coach payouts go through Stripe Connect.
Under the hood
The diagram follows a learner opening /app/podcast. The Next.js middleware reads the tenantId cookie and asks an internal API route for the route settings; that route forwards the call to the backend along with the domain. The backend finds the organisation by domain and returns the routes with their state. If the requested route is disabled, the page is never rendered.
One detail matters: the NestJS controller uses req.tenant.id, the organisation resolved from the domain, not the id passed in the URL. Changing the id in the URL does not expose another organisation’s settings.
// Get domain from header first (for multi-tenant), fallback to hostname
let domain =
req.headers['x-tenant-domain']?.toString() || req.hostname.split(':')[0];
if (!domain && this.defaultPublicTenantDomain) {
domain = this.defaultPublicTenantDomain;
}
const superAdminDomain = this.config.get<string>('SUPER_ADMIN_DOMAIN');
if (domain === superAdminDomain) {
return next();
}
// Look up tenant for the domain
let tenant = await this.prisma.organization.findUnique({
where: { domain },
select: { id: true, domain: true },
});
if (!tenant) throw new NotFoundException('Tenant not found');
req.tenant = tenant;
next();The middleware is short: the header first, then the hostname, a default public organisation, and an early exit for the super-admin domain. If no organisation matches, the request stops with a 404 before reaching any controller.
This session comes from the local setup for these screenshots. The health check answers, but replaying every migration on an empty database fails: the Prisma history no longer replays from scratch. I had to use prisma db push, then adapt the seed, which still sent a ponderation field removed from the schema.
What I take away
- The domain as tenant key is simple and readable: one organisation, one subdomain, one header. The cost is a wildcard certificate and DNS records to keep tidy.
- Toggleable routes beat forks. The previous generations (Appwrite, Directus, white-label) multiplied variants. One switch per route replaced a good share of those copies.
- A migration history must replay. While production moves forward, you don’t notice. You find out the day you start a fresh database, as here.
Gallery




