X Enterprises

nuxt-x-tenancy

Multi-tenant resolution layer for Nuxt 4 — bridges the better-auth active organization into a tenant vocabulary with path/subdomain/header resolution strategies, X-Tenant-ID request injection, and per-tenant useXCrud cache scoping with flush-on-switch.

nuxt-x-tenancy

Multi-tenant resolution layer for Nuxt 4. Bridges the better-auth active organization (via nuxt-x-auth-better) into a tenant vocabulary: URL/host resolution strategies (path, subdomain, header), X-Tenant-ID request injection, and per-tenant useXCrud cache scoping with a flush on switch.

ID contract: tenant id === better-auth organization id. No translation layer anywhere — see Backend requirements.

Installation

npm install @xenterprises/nuxt-x-tenancy

Peers (@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 extends only tenancy and gets the whole stack.

What the consumer writes

The batteries are included: the global tenant middleware, the $fetch header plugin, the useTenant composable, and both XT* components come from the layer. A minimal consumer writes three things:

  1. extends in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
  extends: ['@xenterprises/nuxt-x-tenancy'],
})
  1. app/app.config.ts — it must live in the consuming project's app/ directory (Nuxt 4 srcDir convention); at the project root your overrides are silently ignored. Everything is optional — these are the defaults:
// app/app.config.ts
export default defineAppConfig({
  xTenancy: {
    strategy: 'path',        // 'path' | 'subdomain' | 'header'
    pathPrefix: '/org',      // path strategy: /org/:slug
    // rootDomain: 'app.example.com', // required for the subdomain strategy
  },
})
  1. The auth-better requirements — a better-auth server with the organization plugin enabled and xAuth.organization.enabled: true (plus NUXT_PUBLIC_X_AUTH_BASE_URL). See Backend requirements.

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

Resolution strategies (xTenancy.strategy)

  • path (default) — the tenant.global middleware reads the slug from ${pathPrefix}/:slug (default /org/:slug, matching auth-better's org routes) and activates it. Unresolvable slugs redirect to xTenancy.fallbackRoute when set.
  • subdomain — the middleware maps the request host against xTenancy.rootDomain (acme.app.example.com → slug acme). Requires wildcard DNS.
  • header — no route effects; the tenant follows the session's active org and is carried to APIs via the X-Tenant-ID header.

Full chain details, middleware ordering, and the upstream better-auth caveat: Resolution chain.

Request integration

  • $tenantHeaders() — plugin-provided factory returning { 'X-Tenant-ID': <id> } (or {}); spread it into SSR/custom requests. Header injection is deliberately not done server-side: globalThis.$fetch is shared across SSR requests and a per-request closure would leak tenants.
  • Client $fetch wrapper — every client-side request that doesn't already set the header gets it (per-call headers win, case-insensitive). Covers useXCrud, which reads globalThis.$fetch at call time. Opt out: xTenancy.injectHeader: false.
  • CRUD scope — pass scope to useXCrud; a Ref scope means live lists refetch on switch into a fresh cache entry, and switchTenant() calls useXCrud.clearScope(oldScope) so the old tenant's cache is dropped. Opt out: xTenancy.crudScope: false.

What the layer provides

Components (auto-imported, XT* prefix):

  • XTTenantSwitcherUSelectMenu 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.

Composables:

  • useTenant() — tenant bridge: tenant, tenants, isResolving, scope, switchTenant(id), resolveBySlug(slug).

Middleware & plugins:

  • tenant.global — path/subdomain resolution (runs after auth-better's auth.global/organization.global).
  • tenant plugin — provides $tenantHeaders() and installs the client-side $fetch X-Tenant-ID wrapper.

Pages / server routes: none — the layer is client/middleware-only by design. Tenant pages are the consumer's or auth-better's /org/* routes; the backend contract is documented in Backend requirements.

App config options (xTenancy)

KeyTypeDefaultDescription
enabledbooleantrueMaster switch (middleware + plugin).
strategy'path' | 'subdomain' | 'header''path'Resolution strategy.
pathPrefixstring'/org'Tenant URL prefix (path strategy).
rootDomainstringundefinedRoot domain (subdomain strategy, required there).
headerNamestring'X-Tenant-ID'Tenant header name.
injectHeaderbooleantrueClient-side global $fetch header injection.
crudScopebooleantrueScope flushing on switchTenant().
fallbackRoutestringundefinedRedirect target for unresolvable slugs.

Environment variables

None of its own. Inherits NUXT_PUBLIC_X_AUTH_BASE_URL / auth-path from nuxt-x-auth-better.


AI Context

package: "@xenterprises/nuxt-x-tenancy"
version: "0.1.0"
use-when: >
  Building a multi-tenant Nuxt 4 SaaS on better-auth organizations. The
  layer resolves the active tenant from the URL (path or subdomain) or
  treats it as API-only (header), injects X-Tenant-ID into every client
  request, and scopes useXCrud caches per tenant with a flush on switch.
  tenant id === better-auth organization id. Extends nuxt-x-app and
  nuxt-x-auth-better itself, so consumers write a single extends entry.
Copyright © 2026