@xenterprises/nuxt-x-tenancy
v0.1.0
Published
Multi-tenant resolution layer for Nuxt: bridges better-auth active organization to tenant scope, X-Tenant-ID header injection, and useXCrud cache scoping
Readme
@xenterprises/nuxt-x-tenancy
Multi-tenant resolution layer for Nuxt 4. Bridges the better-auth active organization (via @xenterprises/nuxt-x-auth-better) into a tenant vocabulary, resolves tenants from the URL (path, subdomain) or treats them as API-only (header), injects X-Tenant-ID into outgoing requests, and scopes useXCrud caches per tenant with a flush on switch.
ID contract: tenant id === better-auth organization id. No translation layer — see BACKEND-REQUIREMENTS.md.
Install
npm install @xenterprises/nuxt-x-tenancyPeers (@xenterprises/nuxt-x-app ^1.1.0, @xenterprises/nuxt-x-auth-better ^1.2.0, nuxt ^4.0.0) are auto-installed by npm ≥ 7. The layer's own nuxt.config.ts extends both sibling layers, so the consumer writes only:
// nuxt.config.ts
export default defineNuxtConfig({
extends: ['@xenterprises/nuxt-x-tenancy'],
})// app/app.config.ts — everything optional; these are the defaults
export default defineAppConfig({
xTenancy: {
strategy: 'path', // 'path' | 'subdomain' | 'header'
pathPrefix: '/org', // path strategy: /org/:slug
// rootDomain: 'app.example.com', // required for subdomain strategy
},
})Plus the auth-better requirements (better-auth server with the organization plugin, xAuth.organization.enabled: true) — see BACKEND-REQUIREMENTS.md.
What the consumer writes (quickstart)
extends: ['@xenterprises/nuxt-x-tenancy'](above).- Optional
xTenancyoverrides inapp/app.config.ts. - Pass the tenant scope to tenant-scoped CRUD calls:
<script setup lang="ts">
const { tenant, tenants, isResolving, scope, switchTenant } = useTenant()
const tasks = useXCrud<Task>('/api/tasks', { scope }).all()
</script>
<template>
<XTTenantSwitcher />
<XTTenantResolver>
<template #default="{ tenant: active }">
<p>Active: {{ active.name }}</p>
</template>
</XTTenantResolver>
</template>That's it — the global middleware resolves /org/:slug (or the subdomain) into the active organization, the request plugin adds X-Tenant-ID to every $fetch/useXCrud call, and switchTenant() flushes the old tenant's CRUD cache.
useTenant()
| Return | Type | Description |
|---|---|---|
| tenant | ComputedRef<Tenant \| null> | Current tenant (better-auth active org, mapped). |
| tenants | ComputedRef<Tenant[]> | All organizations the user belongs to. |
| isResolving | ComputedRef<boolean> | True while switching/resolving or org data loads. |
| scope | ComputedRef<string> | Tenant id for useXCrud's scope option ('global' when none). |
| switchTenant(id) | (id: string) => Promise<void> | Sets active org + useXCrud.clearScope(oldScope). |
| resolveBySlug(slug) | (slug: string) => Promise<boolean> | Resolve (from the organizations list) + activate by slug; used by the middleware. |
Tenant = { id, name, slug, logo? }; toTenant(org) maps any better-auth-shaped organization.
Resolution strategies (xTenancy.strategy)
path(default) —tenant.globalmiddleware reads the slug from${pathPrefix}/:slug(default/org/:slug, matching auth-better's org routes) and activates it. Unresolvable slugs redirect toxTenancy.fallbackRoutewhen set.subdomain— middleware maps the request host againstxTenancy.rootDomain(acme.app.example.com→ slugacme). Requires wildcard DNS.header— no route effects; the tenant follows the session's active org and is carried to APIs via theX-Tenant-IDheader.
Request integration
$tenantHeaders()— plugin-provided factory returning{ 'X-Tenant-ID': <id> }(or{}); spread it into SSR/custom requests.- Client
$fetchwrapper — every request that doesn't already set the header gets it (per-call headers win). CoversuseXCrud, which readsglobalThis.$fetchat call time. Opt out:xTenancy.injectHeader: false. - CRUD scope — pass
scopetouseXCrud; a Ref scope means live lists refetch on switch into a fresh cache entry, andswitchTenant()clears the old scope. Opt out:xTenancy.crudScope: false.
Components
| Component | Purpose |
|---|---|
| XTTenantSwitcher | USelectMenu of tenants; selecting one calls switchTenant. Props: placeholder, hideWhenSingle. |
| XTTenantResolver | Gate for tenant-scoped content: loading slot while isResolving, default slot (scoped tenant) when active, empty slot otherwise. |
Configuration (xTenancy)
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true | Master switch (middleware + plugin). |
| strategy | 'path' \| 'subdomain' \| 'header' | 'path' | Resolution strategy. |
| pathPrefix | string | '/org' | Tenant URL prefix (path strategy). |
| rootDomain | string | undefined | Root domain (subdomain strategy, required there). |
| headerName | string | 'X-Tenant-ID' | Tenant header name. |
| injectHeader | boolean | true | Client-side global $fetch header injection. |
| crudScope | boolean | true | Scope flushing on switchTenant. |
| fallbackRoute | string | undefined | Redirect target for unresolvable slugs. |
Environment variables
None of its own. Inherits NUXT_PUBLIC_X_AUTH_BASE_URL / auth-path from nuxt-x-auth-better.
Development
npm install
npm run dev # playground on :3115 (mocked better-auth + mock tenant API)
npm run typecheck
npm run test:run # logic-only unit tests (#imports mocked)
npm run test:coverage
npm run lint
npm run test:e2e # Playwright smoke, builds + previews on :3115