multitenant — скилл для Claude Code: мультитенантность в Next.js
Скилл для Vovk.ts: разводит несколько тенантов по поддоменам в одном Next.js-приложении — прокси, сегменты, страницы.
npx skills add finom/vovk --skill multitenant -a claude-codeЧто делает multitenant
Скилл описывает, как поднять несколько тенантов из одного Next.js-приложения на Vovk.ts. Схема такая: example.com — корневой тенант, admin.example.com — админка, customer.example.com — клиентский портал, <customer>.customer.example.com — инстанс конкретного клиента. Форму выбираете сами, скилл показывает обвязку под любую.
Работает в три слоя. src/proxy.ts ловит каждый запрос, зовёт multitenant() из vovk с URL, хостом и картой overrides и получает обратно { action, destination }. Дальше ветвление на NextResponse.rewrite, NextResponse.redirect или 404. Прокси именно переписывает путь, а не редиректит, поэтому admin.example.com/ остаётся в адресной строке, а Next.js внутри отдаёт src/app/admin/page.tsx. Бэкенд — именованный сегмент на тенанта, фронтенд — своя страница на тенанта.
В vovk.config.mjs нужно переключиться на сегментированный клиент: composedClient.enabled: false и segmentedClient.enabled: true. Плюс segmentNameOverride: '' для каждого тенанта, чтобы имя сегмента не попадало в URL клиентских вызовов. В targetHost подставляется корневой домен, и тут есть подвох: без ветки по process.env.VERCEL локальная разработка уйдёт на боевой хостнейм вместо localhost:3000. Создание отдельного сегмента, контроллеры внутри него и тонкости RPC скилл не разбирает — для этого есть соседние скиллы segment, procedure и rpc.
Какие задачи решает
- развести несколько клиентов по поддоменам в одном Next.js-приложении
- настроить переписывание путей через
proxy.tsбез редиректов - выдать каждому тенанту свой сегмент API и свою страницу
- подключить wildcard-поддомены и локальные поддомены на
localhost - переключить проект с composed-клиента на segmented
Примеры запросов агенту
- Настрой мультитенантность:
admin.example.comиcustomer.example.comдолжны обслуживаться одним приложением - Свяжи поддомен
<customer>.customer.example.comс сегментомcustomer/[customer_name]черезmultitenant() - Переключи
vovk.config.mjsна сегментированный клиент и проставьsegmentNameOverrideдля тенантов
Как установить multitenant
1Через Skills CLI (Vercel)
npx skills add finom/vovk --skill multitenant -a claude-code2Вручную: положить файлы
~/.claude/skills/multitenant/SKILL.mdПапка скилла целиком; для одного проекта — .claude/skills/multitenant/
1Через Skills CLI (Vercel)
npx skills add finom/vovk --skill multitenant -a codex2Вручную: положить файлы
.agents/skills/multitenant/SKILL.mdДля всех проектов — ~/.agents/skills/
1Через Skills CLI (Vercel)
npx skills add finom/vovk --skill multitenant -a cursor2Вручную: положить файлы
.cursor/skills/multitenant/SKILL.mdCursor читает и .agents/skills/, и .claude/skills/
1GitHub CLI
gh skill install finom/vovk multitenant2Вручную: положить файлы
.github/skills/multitenant/SKILL.md1Через Skills CLI (Vercel)
npx skills add finom/vovk --skill multitenant -a gemini-cli2Командой платформы
gemini skills install https://github.com/finom/vovk --path skills/multitenantВручную: положить файлы
.devin/skills/multitenant/SKILL.mdСтарый путь .windsurf/skills/ тоже читается
Через Skills CLI (Vercel)
npx skills add finom/vovk --skill multitenant -a opencodeClaude.ai
Упакуйте папку в zip и загрузите в Settings → Capabilities → Skills
По шагам, с командами для каждой платформы: Как установить скилл в Claude Code: пять способов
Вопросы о multitenant
Как установить multitenant в Claude Code?
Выполните npx skills add finom/vovk --skill multitenant -a claude-code. Файл скилла появится по пути ~/.claude/skills/multitenant/SKILL.md.
Что делает скилл multitenant?
Показывает, как обслуживать несколько тенантов из одного Next.js-приложения на Vovk.ts: прокси с multitenant(), карта overrides, сегменты на бэкенде и страницы на фронтенде.
Чем этот скилл отличается от segment и procedure?
Здесь только обвязка мультитенантности. Создание и настройка отдельного сегмента — в скилле segment, написание контроллеров внутри сегмента — в procedure, детали сегментированного клиента — в rpc.
Исходный текст
SKILL.md · MIT
---
name: multitenant
description: Multi-tenant / multitenancy patterns in Vovk.ts — hosting multiple tenants from one Next.js app via subdomain routing. Covers the `multitenant()` helper from `vovk`, the Next.js `proxy.ts` file that wires it up, the `overrides` shape (static / dynamic / nested subdomain patterns), the `vovk.config.mjs` changes needed (switch to segmented client, `segmentNameOverride`), per-tenant frontend page routes, wildcard DNS, and local subdomains under `localhost`. Triggers on phrasings like "multi-tenant", "multitenancy", "per-tenant API", "per-tenant frontend", "host `admin.example.com`", "serve per subdomain", "tenant per customer", "wildcard subdomains", "route subdomain to segment". Does NOT cover creating or configuring an individual segment — hand off to the `segment` skill. Does NOT cover writing controllers inside a tenant segment — hand off to the `procedure` skill. Does NOT cover composed-vs-segmented client specifics beyond the flip — hand off to the `rpc` skill.
---
# Vovk.ts multi-tenant routing
Host multiple tenants from one Next.js app. Each tenant gets own **named segment** on backend, own **frontend page route** on frontend, wired together with subdomain-aware **proxy** calling `multitenant()` from `vovk`.
Typical shape:
- `example.com/` → root tenant (marketing home, or tenant picker).
- `admin.example.com/` → admin tenant — internal backend + UI.
- `customer.example.com/` → customer-facing portal (signup, landing, etc.).
- `<customer>.customer.example.com/` → per-customer instance.
- `pro.<customer>.customer.example.com/` → per-customer pro tier.
Pick any shape; this skill shows wiring for any.
## Architecture
Three layers, one request:
1. **Proxy (`src/proxy.ts`)** — intercepts every request, calls `multitenant()` with incoming URL, host, your `overrides` map, gets back `{ action, destination }`. Branches into `NextResponse.rewrite`, `NextResponse.redirect`, or 404.
2. **Named segment per tenant** on backend (`src/app/api/admin/[[...vovk]]/route.ts`, etc.). Each tenant's `api/*` calls land here after proxy rewrite.
3. **Per-tenant frontend page routes** on frontend (`src/app/admin/page.tsx`, etc.). Each tenant's non-api requests land here after proxy rewrite.
Proxy **rewrites** — does NOT redirect (except reserved-path guard, see below) — so `admin.example.com/` stays in user's address bar while Next.js internally serves `src/app/admin/page.tsx`.
## 1. Create a segment per tenant
```bash
npx vovk new segment admin
npx vovk new segment customer
npx vovk new segment customer/pro
```
Controllers go in `src/modules/...` as usual. See **`segment` skill** for segment shapes, `initSegment()`, nested paths; see **`procedure`** for controller authoring.
## 2. Switch to segmented client and override segment names
In `vovk.config.mjs`:
```js
/** @type {import('vovk').VovkConfig} */
const config = {
composedClient: { enabled: false },
segmentedClient: { enabled: true },
outputConfig: {
segments: {
admin: { segmentNameOverride: '' },
customer: { segmentNameOverride: '' },
'customer/pro': { segmentNameOverride: '' },
},
},
};
export default config;
```
Why:
- `composedClient.enabled: false` + `segmentedClient.enabled: true` — switches from single all-segments RPC bundle to one client per segment. Each tenant's frontend imports only RPC it uses → lean bundles.
- `segmentNameOverride: ''` per tenant — leaves the segment name out of the URLs that segment's client calls (`/api/users`, not `/api/admin/users`), so the proxy's `{ from: 'api', to: 'api/<tenant>' }` rewrite routes them to the tenant's segment. The client folder keeps the segment name: `admin` pages import `@/client/admin`.
See **`rpc` skill** for full segmented-client reference.
## 3. Wire the proxy
`src/proxy.ts` (or `proxy.ts` at project root if `src/` not used):
```ts
import { NextRequest, NextResponse } from 'next/server';
import { multitenant } from 'vovk';
export default function proxy(request: NextRequest) {
const { action, destination } = multitenant({
requestUrl: request.url,
requestHost: request.headers.get('host') ?? '',
targetHost: process.env.VERCEL ? 'example.com' : 'localhost:3000',
overrides: {
admin: [
{ from: 'api', to: 'api/admin' },
{ from: '', to: 'admin' },
],
customer: [
{ from: 'api', to: 'api/customer' },
{ from: '', to: 'customer' },
],
'[customer_name].customer': [
{ from: 'api', to: 'api/customer' },
{ from: '', to: 'customer/[customer_name]' },
],
'pro.[customer_name].customer': [
{ from: 'api', to: 'api/customer/pro' },
{ from: '', to: 'customer/[customer_name]/pro' },
],
},
});
if (action === 'rewrite' && destination) return NextResponse.rewrite(new URL(destination));
if (action === 'redirect' && destination) return NextResponse.redirect(new URL(destination));
return NextResponse.next();
}
export const config = {
matcher: ['/((?!static|.*\\.png|.*\\.svg|.*\\.ico|.well-known|_next/image|_next/static).*)'],
};
```
`targetHost` is your root domain — branch on `process.env.VERCEL` (or your own flag) so local dev uses `localhost:3000`, prod uses real hostname.
## 4. Per-tenant frontend pages
Each tenant override maps `from: ''` → `to: '<tenant>'`. Non-api requests to tenant subdomain rewrite to `/<tenant>` internally; Next.js serves from `src/app/<tenant>/page.tsx`.
File tree mirroring proxy example above:
```
src/app/
page.tsx ← root tenant (example.com/)
admin/page.tsx ← admin tenant (admin.example.com/)
customer/page.tsx ← customer landing (customer.example.com/)
customer/[customer_name]/page.tsx ← per-customer instance (<name>.customer.example.com/)
customer/[customer_name]/pro/page.tsx ← per-customer pro (pro.<name>.customer.example.com/)
```
`[customer_name]` is Next.js dynamic route segment. Populated from subdomain capture — `acme.customer.example.com` hits `[customer_name].customer` override → rewrites to `/customer/acme` → Next.js renders `src/app/customer/[customer_name]/page.tsx` with `params.customer_name === 'acme'`.
Mapping cheat sheet:
| Subdomain pattern | Override `to` (frontend) | Next.js page file |
|---|---|---|
| `admin.<root>` | `admin` | `src/app/admin/page.tsx` |
| `customer.<root>` | `customer` | `src/app/customer/page.tsx` |
| `<name>.customer.<root>` | `customer/[customer_name]` | `src/app/customer/[customer_name]/page.tsx` |
| `pro.<name>.customer.<root>` | `customer/[customer_name]/pro` | `src/app/customer/[customer_name]/pro/page.tsx` |
Inside per-tenant page:
- Import that tenant's segmented client from `@/client/<segment>` (`@/client/admin`, `@/client/customer`, `@/client/customer/pro`): `segmentedClient.outDir` (default `src/client`) plus the segment name. `segmentNameOverride` changes the URLs, not this path.
- Call RPC normally — `{ from: 'api', to: 'api/<tenant>' }` override rewrites every `fetch('/api/...')` so it lands in that tenant's segment on backend.
### Deeper frontend trees
Nest regular pages under tenant's folder. `src/app/admin/users/page.tsx` serves `admin.example.com/users` — proxy rewrite `to: 'admin'` becomes `to: 'admin'` for `/` and `admin/users` for `/users` (because `multitenant()` rewrites prefix, not whole path).
## The `multitenant()` helper
Signature (simplified):
```ts
multitenant({
requestUrl: string, // request.url
requestHost: string, // request.headers.get('host')
targetHost: string, // your root domain, e.g. 'example.com' or 'localhost:3000'
overrides: Record<string, Array<{ from: string; to: string }>>,
}): {
action: 'rewrite' | 'redirect' | null;
destination: string | null;
subdomains: Record<string, string> | null; // captured [placeholder] values, if any
message: string; // human-readable summary for logs
}
```
### Overrides shape
| Key shape | Matches | Typical use |
|---|---|---|
| Static (`admin`) | `admin.<targetHost>` exactly | Fixed tenant (admin, customer, staff, …) |
| Dynamic (`[customer_name].customer`) | `<anything>.customer.<targetHost>` | Per-customer instance |
| Nested (`pro.[customer_name].customer`) | `pro.<anything>.customer.<targetHost>` | Tier-scoped per-customer |
Each value is list of `{ from, to }` path rewrites applied when host matches. Conventions:
- `{ from: 'api', to: 'api/<tenant>' }` — `/<api>/x` → `/<api>/<tenant>/x`, so RPC calls land in tenant segment.
- `{ from: '', to: '<tenant>' }` — `/` → `/<tenant>`, so frontend hits tenant's page tree.
`[placeholders]` in key are substituted into `to` paths: `'[customer_name].customer'` + `{ to: 'customer/[customer_name]' }` → `customer/acme` when host is `acme.customer.example.com`.
### Reserved-path guard
If request arrives on **root host** with path starting with reserved tenant name (e.g. `example.com/admin/...`), `multitenant()` returns `redirect` (not rewrite) to proper subdomain (`admin.example.com/...`). Keeps root tenant from accidentally serving tenant-scoped URLs and stops users from bookmarking wrong form of link.
"Reserved" = any `overrides` key without dynamic `[placeholders]`. Dynamic keys (`[customer_name].customer`) don't count as reserved paths.
### Return `action` values
| `action` | Proxy does | When |
|---|---|---|
| `'rewrite'` | `NextResponse.rewrite(destination)` | Normal tenant routing — host matches an override and path matches a `from`. |
| `'redirect'` | `NextResponse.redirect(destination)` | Root-host access to reserved tenant path (guard above). |
| `null` | `NextResponse.next()` | Pass-through — not a tenant request (schema endpoint, unmatched host, etc.). |
Some docs examples branch on `action === 'notfound'` and return 404. Current `multitenant()` helper never produces that value (only `'rewrite'`, `'redirect'`, or `null`), so `'notfound'` branch is dead code. Drop it unless handling a 404 yourself for some other reason.
`subdomains` carries captured dynamic values (e.g. `{ customer_name: 'acme' }`) — useful for structured logging, analytics, or feeding into request context.
Helper also short-circuits on `_schema_` paths (Vovk's dev schema endpoint), returning `action: null`, so `vovk dev` / client generation keeps working without extra proxy config.
## 5. DNS / local dev
- **Production**: wildcard DNS at right level. For `<customer>.customer.example.com` need `*.customer.example.com` → host. On Vercel, CNAME `*.multitenant` → `cname.vercel-dns.com.` covers one level; nested patterns need a record at each level you want to catch.
- **Local**: browsers resolve every name under `localhost` to the loopback address, so `admin.localhost:3000`, `acme.customer.localhost:3000`, etc. reach the dev server with no `/etc/hosts` change. A hosts file takes no wildcards, so a `*.localhost` line does nothing.
## Adding a new tenant
1. `npx vovk new segment <tenant>` — creates `src/app/api/<tenant>/[[...vovk]]/route.ts`.
2. Create frontend page tree under `src/app/<tenant>/...` (or `src/app/<parent>/[slug]/...` for dynamic tenants).
3. Add `overrides` entry in `src/proxy.ts` mapping subdomain pattern to both API path (`api` → `api/<tenant>`) and frontend path (`''` → `<tenant>`).
4. Add `segmentNameOverride: ''` under `outputConfig.segments.<tenant>` in `vovk.config.mjs`.
5. Run `vovk dev` / `vovk generate` to regenerate segmented client.
6. **Prod**: confirm wildcard DNS covers new subdomain. **Local**: nothing to do; every name under `localhost` works.
## Gotchas
- **Proxy filename is fixed**: must be `proxy.ts` (or `.js`) at `src/proxy.ts` or project root. Exported default function can be named anything. (On older Next.js versions same file is called `middleware.ts` — follow whatever convention your Next.js version expects.)
- **Matcher excludes static assets**: `config.matcher` regex skips `_next/*`, `*.png`, `.well-known/*`, etc. Keep those exclusions — without them, asset requests get rewritten and blow up.
- **Wildcard DNS is one level deep**: `*.example.com` covers `admin.example.com` but NOT `foo.admin.example.com`. For patterns like `pro.[customer_name].customer`, need wildcard at each level (`*.customer.example.com`, and potentially more). Check your DNS provider's wildcard semantics.
- **Rewrite vs. redirect**: `rewrite` keeps tenant URL visible in address bar; `redirect` sends 302 to destination. `multitenant()` picks the right one — don't swap them manually in branch.
- **Reserved names can't overlap with real URL paths**: if you have tenant key `admin` and root-tenant page at `src/app/admin/page.tsx`, reserved-path guard redirects `example.com/admin` → `admin.example.com/`, making that page unreachable from root host. Either rename tenant or root page.
- **Sharing controllers across tenants**: if tenants share logic, import same controller class into multiple tenant segments' `controllers` maps (see `segment` skill). Per-tenant customization belongs in controller (e.g. `req.vovk.meta()` for tenant context).
## Common flows
### "Add an admin subdomain"
Short version: `npx vovk new segment admin` → `src/app/admin/page.tsx` → `overrides.admin` in `proxy.ts` → `segmentNameOverride: ''` in `vovk.config.mjs` → done.
### "Each customer gets their own subdomain"
Use dynamic override: `'[customer_name].customer': [...]` with `customer/[customer_name]` on frontend. One segment (`customer`) serves them all; per-customer logic reads subdomain from `request` or `subdomains` field returned by `multitenant()`.
### "Self-hosted per-tenant database"
Vovk doesn't manage DB isolation — that's application-level. Resolve tenant in controller using `req.headers.get('host')` or middleware-injected request header, then route DB calls accordingly. Per-tenant connection pooling / sharding is your concern.
### "Migrating an existing single-tenant app to multi-tenant"
1. Move existing root-tenant pages under root `src/app/` tree (they already live there).
2. Add new tenant segment + page tree for each new tenant.
3. Flip to segmented client in `vovk.config.mjs`, add `segmentNameOverride: ''` for NEW tenants — root tenant keeps its default.
4. Add `proxy.ts` with new tenants' overrides; don't override anything for root.
5. Re-run `vovk dev` / `vovk generate`.
© finom · MIT · finom/vovk