Skip to content
← ProjectsDevInfraIn production

Multi-tenant learning platform with per-organisation modules.

Role
Lead full-stack & infra developer
Period
2024 — aujourd’hui
Civinat

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.

  1. 01Dev

    NestJS + Next.js monorepo

    NestJS 11 / Prisma 6 API, Next.js 16 / MUI / React Query front, Docusaurus docs.

  2. 02Dev

    Multi-tenant

    Modules and routes toggled per organisation, role-based access control.

  3. 03Infra

    Off managed services

    Moved from Neon to self-hosted Postgres, backed up to Cloudflare R2.

  4. 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

Public civinat.com home page: “Pass your naturalisation interview”
The public civinat.com site: the entry point of the main organisation, served by the same Next.js as the app.

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

Admin dashboard of the PM Academy demo organisation
An ADMIN’s dashboard: everything on it is scoped to their organisation, here PM Academy, resolved from the domain.

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

Training module list with CSV import and export
The organisation’s own modules, with CSV import and export: a new customer rarely arrives without existing 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

“Learner route access” screen with one switch per route
One switch per learner route: turning off “Podcast” removes the menu entry and blocks direct access to the URL.

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

An organisation’s PWA settings, with the configuration status
Each organisation gets its own PWA: name, icons and colours feed a manifest generated per domain.

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 screen: Stripe settings, offers and pricing
Billing is set per organisation: Stripe keys, offers (what a subscription unlocks) and prices.

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

Multi-step coach onboarding wizard
On first login, a MENTOR goes through onboarding: profile, availability, then going live.
Coach area: weekly availability management
The coach area: the slots set here are the only ones learners can book.

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

How a disabled route is blocked

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.

backend/src/common/middleware/tenant.middleware.ts (excerpt)
// 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.

backend, empty local database

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

Public civinat.com home page: “Pass your naturalisation interview”
Training module list with CSV import and export
An organisation’s PWA settings, with the configuration status
Multi-step coach onboarding wizard
agent-setup
Next project →agent-setupThe first multi-agent orchestrator, for Claude Code and Codex.

Let’s talk. Describe your need in a few lines, I’ll get back to you within 48 hours.