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.
| Concept | In this app |
|---|---|
| Tenant identity | Subdomain of the hostname (acme.company.local → acme) |
| Tenant config | Fetched from GET /tenants/defaults |
| UI customization | Document title, sidebar branding, feature-gated nav |
| Isolation failure | _auth layout shows “Tenant not found” on API error |
Multi-tenant architectures

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
- Browser hits
https://{slug}.yourdomain.com. getTenant()insrc/lib/tenant.tsreadswindow.location.hostnameand takes the first label.- The backend (via Host header / reverse proxy) resolves the tenant and returns tenant-scoped data.
- The SPA fetches
/tenants/defaultsand 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
| File | Route id | URL path | Role |
|---|---|---|---|
__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
- Create a file under
src/routes/(or nested under_auth/for pages that need the dashboard + tenant gate). - Export
RouteviacreateFileRoute(...). - Let the Vite plugin regenerate
routeTree.gen.tson nextpnpm 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
| Variable | Description | Default |
|---|---|---|
VITE_APP_NAME | App display name | multi-tenant-app |
VITE_API_BASE_URL | API origin / proxy prefix | /api |
VITE_API_VERSION | API version segment | v1 |
Axios calls go to {VITE_API_BASE_URL}{VITE_API_VERSION} (e.g. /apiv1 unless you include a slash in the env values).
Scripts
| Script | Description |
|---|---|
pnpm dev | Start the Vite dev server |
pnpm build | Type-check and build for production |
pnpm preview | Preview the production build |
pnpm lint | Run ESLint |
pnpm format | Format with Prettier |
pnpm typecheck | Run 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 awareness —
planStatusis surfaced in the sidebar header for operational visibility. - Fail closed — if tenant defaults cannot be loaded, the
_authlayout 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