Projects system-design-demo multi-tenancy-architecture Files
.husky Loading last commit info...
images
public
src
.editorconfig
.env.example
.gitignore
.oxlintrc.json
.prettierignore
.prettierrc
README.md
components.json
eslint.config.js
index.html
package.json
pnpm-lock.yaml
tsconfig.app.json
tsconfig.json
tsconfig.node.json
vite.config.ts
README.md

What is multi-tenancy?

Multi-tenancy means one application serves many isolated customers (tenants). Each tenant gets its own data, branding, feature set, and plan — while sharing the same codebase and (usually) the same infrastructure.

ConceptIn this app
Tenant identitySubdomain of the hostname (acme.company.localacme)
Tenant configFetched from GET /tenants/defaults
UI customizationDocument title, sidebar branding, feature-gated nav
Isolation failure_auth layout shows “Tenant not found” on API error

Multi-tenant architectures

App Screenshot

There are three common ways to identify a tenant on the frontend. This project uses subdomain-based tenancy.

1. Subdomain-based (this project)

acme.company.local  →  tenant = "acme"
beta.company.local  →  tenant = "beta"
localhost           →  tenant = "default"  (dev fallback)

How it works

  1. Browser hits https://{slug}.yourdomain.com.
  2. getTenant() in src/lib/tenant.ts reads window.location.hostname and takes the first label.
  3. The backend (via Host header / reverse proxy) resolves the tenant and returns tenant-scoped data.
  4. The SPA fetches /tenants/defaults and applies config (features, theme, plan status).

Local development

Vite is configured to accept wildcard hosts under .company.local:

// vite.config.ts
server: {
  host: '0.0.0.0',
  allowedHosts: ['.company.local'],
}

Map tenants in your hosts file (or local DNS):

127.0.0.1  acme.company.local
127.0.0.1  beta.company.local

Then open http://acme.company.local.

Pros: Clean URLs, easy custom branding per host, works well with cookies / TLS wildcards (*.company.com).
Cons: Needs DNS / hosts setup in local and staging; wildcard certs for HTTPS.

2. Path-based

app.company.com/t/acme/dashboard
app.company.com/t/beta/dashboard

Tenant lives in the URL path. Simpler local setup, but every route must be prefixed and links are noisier. Not used here.

3. Header / token-based

Tenant is sent as an HTTP header (e.g. X-Tenant-ID) or derived from the auth token after login. Good for APIs and mobile clients; less ideal as the sole browser identity because the URL alone does not encode the tenant.


How this app resolves a tenant

┌─────────────────────────────────────────────────────────────┐
│  Browser                                                    │
│  acme.company.local                                    │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  getTenant()  (src/lib/tenant.ts)                           │
│  hostname → first label → "acme"                            │
│  localhost / *.localhost → "default"                        │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  Axios  (src/lib/axios.ts)                                  │
│  baseURL = VITE_API_BASE_URL + VITE_API_VERSION             │
│  Backend uses Host / tenant context for scoping             │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  useTenantDefaults()  (src/hooks/use-tenant.ts)             │
│  GET /tenants/defaults                                      │
│  → id, name, slug, planStatus, config.features, …           │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  UI                                                         │
│  • document.title = tenant.name                             │
│  • TeamSwitcher shows name + plan                           │
│  • Nav items filtered by config.features.*                  │
│  • ErrorPage if tenant cannot be loaded                     │
└─────────────────────────────────────────────────────────────┘

Tenant payload shape (see src/hooks/use-tenant.ts):

interface TenantDefaults {
  id: string
  name: string
  slug: string
  email: string
  planStatus: 'TRIAL' | 'ACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'CANCELLED'
  config: {
    theme: 'light' | 'dark'
    timezone: string
    currency: string
    features: {
      analytics: boolean
      notifications: boolean
      multiLanguage: boolean
    }
  }
  isActive: boolean
  createdAt: string
  updatedAt: string
}

Routing architecture

Routing uses TanStack Router with the Vite plugin (@tanstack/router-plugin). Routes live under src/routes/; the plugin generates src/routeTree.gen.ts (do not edit by hand).

Route tree

src/routes/
├── __root.tsx          # Root layout — QueryClient + ThemeProvider + Outlet
├── _auth.tsx           # Pathless layout — load tenant, gate UI, dashboard chrome
└── _auth/
    └── index.tsx       # `/` — HomePage
FileRoute idURL pathRole
__root.tsx__root__App shell: providers, outlet, router devtools
_auth.tsx/_auth(pathless)Auth/tenant gate + DashboardLayout
_auth/index.tsx/_auth//Home page

The _ prefix on _auth makes it a pathless layout route: it wraps children and runs shared logic without adding a segment to the URL. So / still renders under _auth.

Request flow for /

main.tsx
  └─ createRouter({ routeTree })
       └─ __root__          QueryClientProvider, ThemeProvider
            └─ /_auth       useTenantDefaults()
                 │            loading → Spinner
                 │            error   → ErrorPage (“Tenant not found”)
                 │            success → DashboardLayout (sidebar + header)
                 └─ /       HomePage (tenant welcome)

Adding routes

  1. Create a file under src/routes/ (or nested under _auth/ for pages that need the dashboard + tenant gate).
  2. Export Route via createFileRoute(...).
  3. Let the Vite plugin regenerate routeTree.gen.ts on next pnpm dev / build.

Example — a settings page at /settings:

// src/routes/_auth/settings.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_auth/settings')({
  component: SettingsPage,
})

App bootstrap

// src/main.tsx
const router = createRouter({ routeTree })
// ...
<RouterProvider router={router} />

Providers that need to wrap the whole tree sit in __root.tsx. Feature-level data (tenant) is loaded in _auth so unauthenticated or public routes can be added later as siblings outside _auth.


Folder structure

src/
├── api/                 # API modules / endpoint helpers
├── assets/              # Static assets
├── components/
│   ├── layouts/         # Dashboard shell
│   ├── ui/              # shadcn primitives
│   ├── app-sidebar.tsx  # Tenant-aware navigation
│   └── team-switcher.tsx
├── config/              # env (VITE_*)
├── features/            # Feature modules (e.g. home/HomePage)
├── hooks/
│   └── use-tenant.ts    # Tenant defaults query
├── lib/
│   ├── axios.ts         # HTTP client
│   ├── tenant.ts        # Hostname → tenant slug
│   └── query-client.ts
├── providers/           # Optional app-level providers
├── routes/              # TanStack file routes
├── routeTree.gen.ts     # Generated — do not edit
├── styles/              # Global CSS
└── main.tsx             # Router bootstrap

Path alias

@/* resolves to src/*.


Getting started

pnpm install
cp .env.example .env
pnpm dev

Open the URL printed in the terminal (usually http://localhost:5173), or use a mapped subdomain such as http://acme.company.local.

Environment

VariableDescriptionDefault
VITE_APP_NAMEApp display namemulti-tenant-app
VITE_API_BASE_URLAPI origin / proxy prefix/api
VITE_API_VERSIONAPI version segmentv1

Axios calls go to {VITE_API_BASE_URL}{VITE_API_VERSION} (e.g. /apiv1 unless you include a slash in the env values).


Scripts

ScriptDescription
pnpm devStart the Vite dev server
pnpm buildType-check and build for production
pnpm previewPreview the production build
pnpm lintRun ESLint
pnpm formatFormat with Prettier
pnpm typecheckRun TypeScript without emitting

Reverse Proxy (Caddy Web Server)

  • Caddy acts as the reverse proxy in front of the React SPA and backend.
  • Routes requests based on the Host header (e.g., acme.company.local).
  • Supports wildcard/local subdomains for multi-tenant development.
  • Can terminate HTTPS automatically (in production) and forward requests to the appropriate application/API.

Design notes

  • Single codebase, many tenants — branding and features come from the API, not from separate builds.
  • Feature flags — sidebar items (Analytics, Notifications, Multi Language) are shown only when tenant.config.features.* is true.
  • Plan awarenessplanStatus is surfaced in the sidebar header for operational visibility.
  • Fail closed — if tenant defaults cannot be loaded, the _auth layout renders an error page instead of a half-configured app.

Tech stack

  • Vite + React 19 + TypeScript
  • TanStack Router — file-based, type-safe routing
  • TanStack Query — async state / caching (tenant defaults)
  • Axios — HTTP client
  • Tailwind CSS v4 — utility-first styling
  • shadcn/ui + Lucide React — accessible UI primitives
  • ESLint + Prettier + Husky + lint-staged
Please wait...
Connection lost or session expired, reload to recover
Page is in error, reload to recover