agentsclimarketplace

Multitenant

Skill finom/vovk/skills/multitenant

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 `/etc/hosts` setup. 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.From its SKILL.md

Install
npx -y skills add finom/vovk --skill multitenant

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

SKILL.md

14.8 KB, as published. Nobody here has run it

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.

Scope of this skill

Covers:

  • Multi-tenant architecture (segments + proxy + frontend pages).
  • vovk.config.mjs changes (composed → segmented client, segmentNameOverride).
  • proxy.ts wiring with multitenant() helper from vovk.
  • overrides shape (static, dynamic, nested) and reserved-path guard.
  • Per-tenant frontend page routes, how they pair with overrides.
  • DNS wildcard + local /etc/hosts setup.
  • Add-a-tenant checklist.

Out of scope (hand off):

  • Creating/configuring individual segment (initSegment(), segmentName, nested segments, segment priority) → segment skill.
  • Writing controllers inside tenant segment → procedure skill.
  • Client generation modes (composed vs. segmented, outDir, templates) → rpc skill.

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

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:

/** @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 — strips segment-path prefix from generated client's import path, so each tenant's frontend imports its RPC as if root client. Without this, admin-tenant pages must import from prefixed path.

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):

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 patternOverride to (frontend)Next.js page file
admin.<root>adminsrc/app/admin/page.tsx
customer.<root>customersrc/app/customer/page.tsx
<name>.customer.<root>customer/[customer_name]src/app/customer/[customer_name]/page.tsx
pro.<name>.customer.<root>customer/[customer_name]/prosrc/app/customer/[customer_name]/pro/page.tsx

Inside per-tenant page:

  • Import segmented client for that tenant — import path depends on outputConfig.segmentedClient.outDir and segmentNameOverride: '' setup above. With defaults here, admin-tenant pages import @/client and see admin segment's RPC as "root" client for that frontend.
  • 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):

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 shapeMatchesTypical use
Static (admin)admin.<targetHost> exactlyFixed 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

actionProxy doesWhen
'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).
nullNextResponse.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: add 127.0.0.1 *.localhost to /etc/hosts. Most modern resolvers honor *.localhost without hosts entry, but adding it explicitly is safe bet. Browser access to admin.localhost:3000, acme.customer.localhost:3000, etc., then works.

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: *.localhost already covers it.

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.

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.