Claude Skill

multi-tenant-architecture

Designs tenant isolation, hostname routing, custom-domain lifecycle, and plan limits on Cloudflare or Vercel. Use when asked to "isolate tenant data", "support custom domains", "build a white-label platform", or assess PSL registration. For general module structure use codebase-a

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download mblode-agent-skills-skills_multi-tenant-architecture-24f4fd8.zip · 24 KB
Part of mblode/agent-skills — 22 skills

Install

skills CLI npx skills add https://github.com/mblode/agent-skills/tree/main/skills/multi-tenant-architecture
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install mblode-agent-skills@llmmart
Git git clone https://github.com/mblode/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole mblode/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Multi-Tenant Platform Architecture (Cloudflare or Vercel)

  • IS: platform choice, domain strategy and PSL, tenant identification, compute and data isolation, hostname routing, tenant context propagation, custom domains and SSL, per-tenant static files, and mapping platform limits to plans.
  • IS NOT: general folder structure or module contracts (use codebase-architecture), scaffolding a new repo (use scaffold-nextjs), or the content of per-tenant SEO files once routing serves them dynamically: sitemap entries, canonical URLs, structured data, indexing policy (use seo).

Contents

  • Platform dispatch (decide first)
  • Reference files
  • Workflow (order matters)
  • Gotchas
  • Output schema
  • Pre-commit checklist
  • Related skills

Platform dispatch (decide first)

Signals Platform Model
Tenants upload or generate their own code; code-level isolation; edge compute on KV, D1, Durable Objects, R2 Cloudflare Dispatch Worker in front of a dispatch namespace of per-tenant Workers; Cloudflare for SaaS for custom hostnames
Every tenant runs the same Next.js codebase and differs by content, branding, and plan; ISR, Server Components, Vercel deploys Vercel One deployment; proxy.ts resolves the tenant from the hostname; wildcard plus custom domains on the project
  • Pick one platform per product. Fronting a Vercel app with a Cloudflare proxy doubles the TLS and redirect layers and is the usual cause of redirect loops and failed certificate issuance.
  • Tenants shipping their own code on Vercel is the multi-project model (one Vercel project per tenant, created with the SDK). It follows the Cloudflare row's isolation reasoning; this skill's Vercel references cover the single-deployment model only.

Reference files

File Read when
cloudflare-platform.md Cloudflare chosen: dispatch namespaces, routing, Cloudflare for SaaS custom hostnames, isolation modes, KV and D1 (steps 3 to 7)
vercel-platform.md Vercel chosen: proxy.ts resolution, App Router layout, Global Config lookups, per-tenant static files, custom subpaths, local dev (steps 4 to 7)
vercel-domains.md Vercel chosen: SDK domain lifecycle, DNS targets, verification, wildcard nameservers, SSL, troubleshooting (step 7)
data-isolation.md Step 3 on either platform: shared schema with RLS, schema-per-tenant, database-per-tenant, and the Postgres/Supabase/Drizzle policy pattern
psl.md Step 1 when tenants publish content or run code on sibling subdomains: eligibility, submission, interim cookie controls
limits-and-quotas.md Step 8: dated snapshot of Cloudflare, Vercel, and Neon limits to map onto plans
agents/openai.yaml Never during a task: launcher metadata for external runners

Workflow (order matters)

Copy this checklist to track progress:

Multi-tenant progress:
- [ ] Step 1: Domain strategy and PSL decision
- [ ] Step 2: Tenant identification strategy
- [ ] Step 3: Isolation model (compute and data)
- [ ] Step 4: Deterministic routing
- [ ] Step 5: Tenant context propagation
- [ ] Step 6: Tenant config and least-privilege bindings
- [ ] Step 7: Custom domains and per-tenant static files
- [ ] Step 8: Limits mapped to plans, evidence captured
  1. Choose the domain strategy
  • Put tenant workloads on a dedicated registrable domain (acme.app for tenants, acme.com for brand). One phishing tenant on x.acme.com puts the whole domain on blocklists, and a tenant cookie with Domain=acme.com reaches your dashboard.
  • Keep the dashboard and auth on a different apex from tenant subdomains (app.acme.com for the console, *.acme.app for tenants).
  • If tenants publish content or run code on sibling subdomains, submit the label directly above the tenant name (acme.app, or sites.acme.app for <tenant>.sites.acme.app) to the PSL and start now: there is no SLA. Tenant-owned custom domains need no PSL entry. Otherwise record No PSL with the reason.
  1. Choose tenant identification (one primary; custom domain as the upgrade path)
  • Subdomain tenant.acme.app: wildcard DNS plus wildcard certificate. The default.
  • Custom domain tenant.com: the tenant CNAMEs to you. Paying tenants; reputation shifts to them; needs the onboarding lifecycle in step 7.
  • Path acme.app/tenant: no per-tenant DNS or certificates, but no cookie isolation and no branding. Choose it only when tenants will never get a hostname.
  1. Define the isolation model
  • Compute, Cloudflare: one dispatch namespace in untrusted mode; per-invocation cpuMs and subRequests limits per plan; an outbound Worker if tenant code may call the internet.
  • Compute, Vercel: one deployment, tenant code never executes. If tenants must ship code, move to Vercel multi-project or Cloudflare rather than sandboxing inside the app.
  • Data: shared schema with tenant_id on every tenant-aware table plus RLS is the default; database-per-tenant for regulated or noisy tenants, selectable per plan. See data-isolation.md.
  1. Route deterministically (tenants never influence routing or see each other)
  • Cloudflare: a single */* route on the SaaS zone to the dispatch Worker; hostname -> tenant record (KV, D1 on miss) -> env.DISPATCHER.get(script); Worker not found -> 404.
  • Vercel: proxy.ts (Next.js 16; middleware.ts with runtime: 'nodejs' on 15) reads host, looks the tenant up in Global Config or the database, rewrites into the tenant segment; unknown hostname -> 404, never the brand site.
  • Let /.well-known through before any tenant rewrite. Route robots.txt, sitemap.xml, and llms.txt into the tenant segment so they vary per tenant.
  1. Propagate tenant context from one authority
  • Delete every inbound x-tenant-* header, set x-tenant-id, x-tenant-slug, x-tenant-plan from the resolved tenant, and forward them on the request (NextResponse.next({ request: { headers } })). Server Components read await headers(); route handlers read request.headers. Cloudflare: the dispatch Worker sets headers or passes parameters before fetch.
  • The proxy is routing, not authorization. Server Functions, route handlers, and jobs re-derive the tenant from the session and the data layer enforces it (RLS or tenant_id predicates).
  1. Bind only what the tenant needs
  • Cloudflare: each user Worker gets its own bindings (KV namespace, D1 database, R2 prefix); adding a binding is an explicit redeploy. No shared globals.
  • Vercel: Global Config holds only hostname -> { id, slug, plan }; the database is the source of truth and write-through happens when a domain verifies. Feature flags and branding come from the database keyed by tenant id.
  1. Support custom domains and per-tenant static files
  • Lifecycle to design and record: add domain -> show DNS target -> verify ownership -> certificate issued -> mapping activated -> removal or failure path.
  • Cloudflare: Cloudflare for SaaS custom hostname on the SaaS zone, proxied fallback origin, customers.<you>.com CNAME target, http or txt validation, pre-validate before DNS cutover. See cloudflare-platform.md.
  • Vercel: projectsAddProjectDomain -> DNS values from the project's domain card -> _vercel TXT only if the domain is already on Vercel -> projectsVerifyProjectDomain -> Let's Encrypt HTTP-01. See vercel-domains.md.
  • robots.txt, sitemap.xml, llms.txt are route handlers inside the tenant segment with explicit Content-Type; nothing tenant-specific lives in /public. Their content is seo territory.
  1. Surface limits as plans and capture evidence
  • Fill the limits-to-plan table from limits-and-quotas.md, re-checking each source URL and dating it; enforce at the routing layer (Cloudflare limits, Vercel plan header plus server checks).
  • Nothing long-running in the request path: Cloudflare Queues or Workflows, Vercel background functions or cron.
  • Every tenant operation (create tenant, add domain, verify, remove) works over HTTP with the same authority as the UI; if it only works in the dashboard, the platform leaks into the UI.
  • Run the evidence commands in the pre-commit checklist and paste results into the output.

Gotchas

  • Tenant headers set on the response instead of the request: NextResponse.next({ headers }) sends x-tenant-id to the browser and headers() in Server Components reads nothing. Use NextResponse.next({ request: { headers: requestHeaders } }).
  • Forwarding inbound tenant headers: curl -H "x-tenant-id: <other>" then serves another tenant's data. Delete or overwrite x-tenant-* on every path through the proxy, including paths that skip resolution.
  • The starter kit matcher '/((?!api|_next|[\\w-]+\\.\\w+).*)' excludes every root file with an extension, so robots.txt and sitemap.xml skip the proxy and every tenant gets the platform's /public copy. Match them, and rewrite them into the tenant segment.
  • Next.js 16 renamed middleware.ts to proxy.ts (export proxy, Node.js runtime, a runtime config option throws). npx @next/codemod@canary middleware-to-proxy . migrates. A matcher that excludes a path also skips Server Function POSTs on it, so tenant checks live in the data layer too.
  • Global Config (formerly Edge Config) key names must match ^[\w-]+$; tenant_acme.com is rejected. Use a collision-free encoding or hash; replacing dots with underscores can map different hostnames to the same key. Writes propagate in up to 10 s, so a "domain connected" screen that reads Global Config right after the write shows stale state; read the database there. The legacy @vercel/edge-config SDK cannot read stores connected after the rename (they create GLOBAL_CONFIG, not EDGE_CONFIG).
  • RLS is bypassed by superusers and BYPASSRLS roles; table owners bypass it unless FORCE ROW LEVEL SECURITY is enabled. An app connecting as the migration role sees every tenant with policies "on". Connect as a separate role, add ALTER TABLE ... FORCE ROW LEVEL SECURITY, and test with SET ROLE app_user.
  • SET app.tenant_id = ... outside a transaction on a pooled connection persists into the next request. Use set_config('app.tenant_id', $1, true) inside the transaction; with PgBouncer in transaction mode it is the only safe form.
  • Wildcard *.acme.app on Vercel without Vercel nameservers never gets a certificate: DNS-01 needs Vercel to write _acme-challenge. Point ns1.vercel-dns.com and ns2.vercel-dns.com first and re-add MX records.
  • /.well-known is reserved on Vercel and cannot be rewritten or redirected; a proxy that rewrites every path into /s/[slug] breaks HTTP-01 and custom-domain certificates never issue. Pass it through first.
  • Cloudflare for SaaS: the fallback origin must be a proxied record in the SaaS zone; a custom hostname equal to the zone name is unsupported; _cf-custom-hostname pre-validation does not work when the customer's zone is also on Cloudflare (O2O, marked by cf-connecting-o2o: 1).
  • Untrusted dispatch namespaces (default) have no request.cf and no caches.default, so tenant code reading request.cf.country throws. Trusted mode restores them but shares one cache across every tenant Worker in the namespace.
  • KV is eventually consistent (up to 60 s, negative lookups cached): a hostname added after the dispatch Worker's first lookup 404s for a minute. Fall back to D1 on miss during onboarding.
  • PSL rejects domains with under two years of registration left; the _psl.<suffix> TXT stays in place after merge; browsers ship the list on their own release cycles. Listing also kills Domain=acme.app cookies, including your own cross-subdomain SSO if it lives there.
  • Starting path-based with custom domains on the roadmap means URL rewrites, cookie changes, and DNS migration later.
  • Domain quotas and charges vary by provider and plan. Put current official limits and their access dates in the plan table before setting pricing.

Output schema

Length follows the decisions: drop any section the project does not face rather than filling it.

# Multi-tenant architecture

## Platform decision
- Platform: Cloudflare | Vercel
- Why this platform:
- Rejected platform and reason:

## Domain map
- Brand domain:
- Tenant domain:
- Tenant subdomains:
- Custom domains:
- PSL decision: Submit (suffix, owner, PR link, _psl TXT date) | No PSL (reason)

## Routing matrix
| Host pattern | Resolver | Destination | Unknown tenant behavior |
|---|---|---|---|

## Tenant context flow
- Authority: proxy.ts | dispatch Worker
- Headers set and stripped:
- Server read path:
- Data-layer enforcement:

## Isolation model
- Compute isolation:
- Data isolation (and per-plan variant):
- Config/binding isolation:

## Custom-domain lifecycle
1. DNS target:
2. Ownership verification:
3. Certificate provisioning:
4. Routing activation:
5. Removal/failure path:

## Limits-to-plan table
| Limit | Source URL / access date | Free | Pro | Enterprise | Enforcement point |
|---|---|---:|---:|---:|---|

## Validation evidence
| Check | Command | Expected | Result |
|---|---|---|---|

Pre-commit checklist

  • Platform chosen with rationale; multi-project or Cloudflare chosen if tenants ship code
  • Tenant workloads off the brand domain; dashboard on a separate apex; PSL decision recorded
  • Identification strategy chosen; custom-domain upgrade path defined
  • Isolation model defined for compute and data, including the per-plan variant
  • Routing tenant-blind: unknown host -> 404; /.well-known passes through; static files vary per tenant
  • Inbound x-tenant-* stripped; context set by the proxy or dispatch Worker only; data layer enforces tenant
  • Custom-domain lifecycle defined end to end, including removal
  • Limits table dated from official URLs; enforcement points named; long work off the request path

Evidence commands (run against local or preview; mark N/A with a reason):

Check Command Expected
Tenant boundary exists in code rg -n "x-tenant-id\|CREATE POLICY\|FORCE ROW LEVEL SECURITY\|DISPATCHER.get" . Hits in proxy or dispatch Worker and in the schema
Unknown host is 404 curl -sI -H "Host: nope.acme.app" <url> 404
Forged header ignored curl -s -H "Host: a.acme.app" -H "x-tenant-id: tenant-b" <url>/api/whoami Tenant A
Static files vary curl -s -H "Host: a.acme.app" <url>/robots.txt vs -H "Host: b.acme.app" Different bodies, Content-Type: text/plain
RLS holds for the app role psql -c "BEGIN; SET LOCAL ROLE app_user; SELECT set_config('app.tenant_id','<t1>',true); SELECT count(*) FROM posts; ROLLBACK;" Only tenant t1's rows
ACME path reachable curl -sI -H "Host: tenant.com" <url>/.well-known/acme-challenge/test Not a redirect into the tenant segment
Limits current Access date next to each URL in the limits table Dated within the planning window

Related skills

  • codebase-architecture: folder structure, module contracts, and the request-context pipeline for the application itself.
  • scaffold-nextjs: bootstrap the Next.js turborepo before applying these tenancy patterns.
  • seo: content of per-tenant robots.txt, sitemap.xml, llms.txt, canonical URLs, and structured data once routing serves them.

Maintenance only: evals/evals.json contains regression scenarios for changes to this skill; it does not load during a user task.

Files (agent-skills)
  • agents
    • openai.yaml 296 B
      interface:
        display_name: "Multi-tenant Platform Architecture"
        short_description: "Plan Cloudflare or Vercel tenant architecture"
        default_prompt: "Use $multi-tenant-architecture to design a multi-tenant platform, including domains, routing, tenant isolation, and limits-to-pricing mapping."
      
  • evals
    • evals.json 1.5 KB
      {
        "skill_name": "multi-tenant-architecture",
        "evals": [
          {
            "id": 1,
            "prompt": "Design isolation for shared-schema Postgres. The app currently uses the table owner role; tenant id is set once on a pooled connection.",
            "expected_output": "Specify a restricted app role and transaction-local tenant context.",
            "files": [],
            "assertions": [
              "Distinguishes table owner from BYPASSRLS",
              "Uses transaction-local set_config or equivalent",
              "Includes a cross-tenant read/write check under the app role"
            ]
          },
          {
            "id": 2,
            "prompt": "Store domain mappings for a-b.example.com and a.b-example.com using a key alphabet of letters, digits, underscores, and hyphens.",
            "expected_output": "Choose a collision-free encoding or hash.",
            "files": [],
            "assertions": [
              "Does not replace every separator with underscore",
              "Keeps the original hostname in the authoritative record",
              "Treats an unknown hostname as unresolved rather than another tenant"
            ]
          }
        ],
        "routing": {
          "should_trigger": [
            "Design isolation for shared-schema Postgres. The app currently uses the table owner role; tenant id is set once on a pooled connection.",
            "Store domain mappings for a-b.example.com and a.b-example.com using a key alphabet of letters, digits, underscores, and hyphens."
          ],
          "near_miss": [
            {
              "prompt": "Write the JSON-LD content for this tenant page; routing already works.",
              "expected": "seo"
            }
          ]
        }
      }
      
  • references
    • cloudflare-platform.md 8.8 KB
      # Cloudflare platform primitives (Workers for Platforms + Cloudflare for SaaS)
      
      Applies to steps 3 to 7 when Cloudflare is the chosen platform. Numbers here were checked 2026-09-01; the limits file carries the dated table.
      
      ## Contents
      
      - Architecture
      - Routing (hostname -> tenant -> dispatch)
      - Custom hostnames (Cloudflare for SaaS)
      - Isolation modes and per-tenant limits
      - Data primitives per tenant
      - Local checks
      - Sources
      
      ## Architecture
      
      - **Dispatch namespace**: the container for tenant ("user") Workers. Unlimited scripts, no per-account script cap, every script runs in untrusted mode by default.
      - **Dynamic dispatch Worker**: the only Worker with a route. Resolves the tenant, applies limits, sanitizes the response, and invokes the tenant Worker through the namespace binding.
      - **User Workers**: uploaded with `wrangler deploy --dispatch-namespace <namespace>` or the dispatch namespaces script upload API (`PUT /accounts/{account_id}/workers/dispatch/namespaces/{namespace}/scripts/{script_name}`). Bindings to KV, D1, R2, and Durable Objects are declared per script. No gradual deployments: each upload takes 100% of traffic immediately.
      - **Outbound Worker** (optional): intercepts every `fetch()` a user Worker makes. Use for hostname allowlists, egress logging, and injecting credentials the tenant never sees. Enabling it disables the `connect()` TCP API inside user Workers.
      
      Binding in the dispatch Worker's `wrangler.toml`:
      
      ```toml
      [[dispatch_namespaces]]
      binding = "DISPATCHER"
      namespace = "tenants-prod"
      ```
      
      Dispatch Worker shape:
      
      ```js
      export default {
        async fetch(request, env) {
          const host = new URL(request.url).hostname;
          let tenant = await env.TENANTS.get(host, { type: "json" }); // KV: hostname -> { script, plan, cpuMs, subRequests }
          if (!tenant) tenant = await lookupInD1(env, host);         // KV misses for ~60 s after onboarding
          if (!tenant) return new Response("Not found", { status: 404 });
          try {
            const worker = env.DISPATCHER.get(tenant.script, {}, {
              limits: { cpuMs: tenant.cpuMs, subRequests: tenant.subRequests },
            });
            const headers = new Headers(request.headers);
            headers.set("x-tenant-id", tenant.id);
            headers.set("x-tenant-plan", tenant.plan);
            return await worker.fetch(new Request(request, { headers }));
          } catch (e) {
            if (e.message.startsWith("Worker not found")) return new Response("Not found", { status: 404 });
            throw e;
          }
        },
      };
      ```
      
      ## Routing (hostname -> tenant -> dispatch)
      
      - One `*/*` route on the SaaS zone pointing at the dispatch Worker. Per-hostname routes hit the 1,000 routes-per-zone limit and behave differently for grey-clouded customer DNS; the wildcard is consistent for proxied and unproxied customers and scales to millions of hostnames.
      - `*.saas.example/*` is enough when routing platform subdomains only; it needs a proxied wildcard DNS record.
      - Custom hostnames arrive because the customer's CNAME points at your zone; Cloudflare for SaaS routes them to the fallback origin, which can be a placeholder proxied record when the Worker is the origin.
      - Resolve `hostname -> tenant -> script name`; never derive the script name from the hostname string alone, or a tenant registering `victim.saas.example` picks their neighbor's script.
      
      ## Custom hostnames (Cloudflare for SaaS)
      
      - Bundled on Free, Pro, and Business; add-on for Enterprise. 100 custom hostnames included per zone, then $0.10 per hostname per month; hard cap 50,000 per zone below Enterprise.
      - **Setup**: fallback origin = a proxied `A`, `AAAA`, or `CNAME` record in your zone. Publish a friendly CNAME target such as `customers.<you>.com` and give tenants that, not the zone apex.
      - **Create** per tenant in the dashboard or with the Create Custom Hostname API: `hostname`, `ssl.method` (`http` | `txt` | `email`), minimum TLS version, optional custom origin server. The `POST` response may omit `validation_records`; `GET` the hostname afterwards.
      - **Two validations, two statuses**: hostname ownership (`ownership_verification`, drives `status`) and certificate DCV (`ssl.validation_records`, drives `ssl.status`). HTTP validation completes automatically once DNS points at you. TXT pre-validation (`_cf-custom-hostname.<hostname>` TXT with the returned UUID, or serving `/.well-known/cf-custom-hostname-challenge/<id>` with the token) reaches `active` before DNS cutover, so the tenant sees no downtime; traffic still moves only when the customer changes their DNS target. Wildcard custom hostnames require TXT.
      - Do not create a custom hostname equal to the SaaS zone name.
      - Hostnames over 64 characters need `cloudflare_branding: true` (the certificate CN becomes `sni.cloudflaressl.com`).
      - **Enterprise only**: wildcard custom hostnames, choosing the CA (`certificate_authority`), uploading custom certificates, apex proxying (customer `A` record at their apex), BYOIP. Below Enterprise a tenant apex needs a DNS provider with CNAME flattening, or a `www` redirect.
      - **O2O (Orange-to-Orange)**: the customer's zone is also on Cloudflare and proxied. Their zone's settings apply first, then yours; requires two different accounts; pre-validation is unsupported; requests carry `cf-connecting-o2o: 1`. Custom hostnames behind another CDN are not compatible.
      
      ## Isolation modes and per-tenant limits
      
      - **Untrusted** (default): no `request.cf`, `caches.default` disabled, each Worker has an isolated cache. Required when customers control the code.
      - **Trusted**: `request.cf` available and `caches.default` shared across the namespace, so a Worker can read another tenant's cached responses. Only when you author every script.
      - Per-invocation limits in `DISPATCHER.get(name, {}, { limits: { cpuMs, subRequests } })`; the user Worker throws the moment it exceeds either. Map plan tiers to these values and keep the mapping next to billing.
      - Up to eight tags per script; tag with tenant id and plan for bulk list and delete.
      - Platform ceiling on Workers for Platforms: 30 s CPU per invocation, 15 min per Cron Trigger or Queue consumer invocation.
      
      ## Data primitives per tenant
      
      - **KV** for `hostname -> tenant` in the hot path. Eventually consistent: up to 60 s across locations, negative lookups cached for the default `cacheTtl` of 60 s. Fall back to D1 on miss during onboarding.
      - **D1** for tenant records and, if chosen, database-per-tenant: 10 GB per database on Paid, 50,000 databases per account, 1,000 queries per invocation, 30 s per statement. Each database is a single writer, so shard a busy tenant rather than a busy table.
      - **Durable Objects** for per-tenant coordination and rate limiting; no namespace cap under Workers for Platforms.
      - **R2** with per-tenant prefixes (or buckets for regulated tenants); zero egress fees.
      
      ## Local checks
      
      - `curl -sI -H "Host: tenant.saas.example" http://127.0.0.1:8787/` against `wrangler dev` of the dispatch Worker: expect 200 for a known tenant, 404 for unknown.
      - `curl -s -H "Host: tenant.saas.example" -H "x-tenant-id: other" http://127.0.0.1:8787/whoami`: expect the resolved tenant, not `other`.
      
      ## Sources
      
      Accessed 2026-09-01.
      
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/reference/how-workers-for-platforms-works/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/custom-limits/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/outbound-workers/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/get-started/hostname-routing/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/worker-isolation/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/limits/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/pricing/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/plans/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/start/getting-started/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/create-custom-hostnames/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/hostname-validation/pre-validation/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/security/certificate-management/issue-and-validate/validate-certificates/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/saas-customers/how-it-works/
      - https://developers.cloudflare.com/workers/platform/limits/
      - https://developers.cloudflare.com/kv/concepts/how-kv-works/
      - https://developers.cloudflare.com/d1/platform/limits/
      
    • data-isolation.md 6.2 KB
      # Tenant data isolation
      
      Applies to step 3 on either platform. Pick a model per plan tier, then implement the enforcement so a missing `WHERE tenant_id` cannot leak.
      
      ## Contents
      
      - Choosing a model
      - Shared schema with tenant_id and RLS (default)
      - RLS implementation notes (Postgres, Supabase, Drizzle)
      - Schema-per-tenant
      - Database-per-tenant
      - Evidence
      - Sources
      
      ## Choosing a model
      
      | Model | Isolation | Operational cost | Fits |
      |-------|-----------|------------------|------|
      | Shared schema, `tenant_id` column, RLS | Logical, enforced in the database | One migration, one pool, large tables need tuning as they grow | Default for SaaS; every tenant on the same features |
      | Schema-per-tenant | Namespace | N migrations per release, `search_path` per request, catalog bloat, no per-tenant restore on Neon | Rare; legacy per-customer customizations |
      | Database-per-tenant (Neon project or D1 database per tenant) | Physical | Provisioning API, one connection reference per tenant, fleet migrations; per-tenant PITR, region pinning, scale-to-zero for idle tenants | Regulated or noisy tenants, data-residency, tenant-owned exports |
      
      Hybrid is normal: shared schema for Free and Pro, a dedicated database for the Enterprise tier, selected by `tenant.plan` in the data layer. The tenant row carries the connection reference either way.
      
      ## Shared schema with tenant_id and RLS (default)
      
      ```sql
      ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
      ALTER TABLE posts FORCE ROW LEVEL SECURITY;          -- owners are bound too
      
      CREATE POLICY posts_tenant ON posts
        FOR ALL TO app_user
        USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
        WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
      
      CREATE INDEX posts_tenant_id_idx ON posts (tenant_id);
      ```
      
      Per request, inside one transaction on the app role:
      
      ```sql
      BEGIN;
      SELECT set_config('app.tenant_id', $1, true);        -- true = local to this transaction
      -- queries
      COMMIT;
      ```
      
      - `set_config(..., true)` or `SET LOCAL` only. A plain `SET` on a pooled connection survives the request and the next tenant inherits it; with PgBouncer in transaction mode the transaction-local form is the only safe one.
      - Two roles: a migration role that owns tables, and `app_user` that the application connects as. Superusers, `BYPASSRLS` roles, and table owners (without `FORCE`) skip every policy, so an app connected as the owner sees all tenants with RLS "on".
      - RLS enabled with no policy is default-deny: a forgotten policy shows up as empty results, not a leak. The dangerous failure is the reverse (owner connection or a permissive `USING (true)` policy), so test both directions.
      - Views run with the definer's privileges by default and bypass RLS; set `security_invoker = true` (Postgres 15+).
      - Grants and policies are separate: revoke default grants, then grant per role. A table protected only by policies still accepts `INSERT` from a role that keeps the grant.
      - `current_setting('name', true)` returns `NULL` instead of erroring when unset, which makes the policy evaluate to no rows rather than failing the query; log the unset case in the app so it never passes silently.
      
      ## RLS implementation notes (Postgres, Supabase, Drizzle)
      
      - **Supabase**: identify the tenant from the JWT, `USING (tenant_id = (select (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid))`. Wrap the function in `select` so Postgres evaluates it once per query; read `app_metadata` (server-set) never `user_metadata` (user-editable); scope policies `TO authenticated`; index every column a policy filters on.
      - **Drizzle**: `pgTable.withRLS('posts', {...})` or add `pgPolicy(...)` as a table extra (adding a policy enables RLS); `pgRole('app_user')` or `.existing()`; set the tenant with `tx.execute(sql\`select set_config('app.tenant_id', ${id}, true)\`)` inside `db.transaction`.
      - **Neon**: plain Postgres RLS as above; Neon's own multitenancy guide recommends project-per-tenant when isolation or per-tenant restore matters, and warns that shared-schema compliance work grows with tenant count.
      - Keep the `tenant_id` predicate in application queries as well; RLS is the last line, and the explicit predicate keeps the planner on the index.
      
      ## Schema-per-tenant
      
      - `CREATE SCHEMA t_<id>` per tenant; per request `SET LOCAL search_path = t_<id>, public` inside the transaction.
      - Migrations run once per schema; thousands of schemas bloat the catalog and slow `pg_dump` and planning. Neon notes it saves nothing operationally over separate databases and forfeits per-tenant PITR.
      - Choose it only when tenants need divergent table shapes and you cannot afford separate databases.
      
      ## Database-per-tenant
      
      - **Neon**: one project per tenant via the API; compute scales to zero after 5 minutes of inactivity on Free and Launch (configurable on Scale), so idle tenants cost close to nothing. Included projects: 100 on Free and Launch, 1,000 on Scale (soft limit). Per-tenant point-in-time restore and region choice come free with the model.
      - **D1**: one database per tenant under the 50,000 databases per account cap on Workers Paid, 10 GB each; bind the tenant's database in its user Worker or open it by id from the dispatch Worker.
      - Store the connection reference (Neon connection string or D1 database id) on the tenant row; run migrations as a fleet job that iterates tenants and records the applied version per tenant.
      - Connection pooling: one pool per active tenant is fine on Neon's pooled endpoint; keep pools small and lazily created.
      
      ## Evidence
      
      ```bash
      psql "$DATABASE_URL" -c "BEGIN; SET LOCAL ROLE app_user; SELECT set_config('app.tenant_id','<tenant-a>',true); SELECT count(*) FROM posts; ROLLBACK;"
      psql "$DATABASE_URL" -c "BEGIN; SET LOCAL ROLE app_user; SELECT count(*) FROM posts; ROLLBACK;"   # unset tenant: expect 0
      ```
      
      The first count matches tenant A's rows only; the second returns 0. A count equal to the full table in either case means the role owns the table, has `BYPASSRLS`, or a policy uses `USING (true)`.
      
      ## Sources
      
      Accessed 2026-09-01.
      
      - https://www.postgresql.org/docs/current/ddl-rowsecurity.html
      - https://supabase.com/docs/guides/database/postgres/row-level-security
      - https://orm.drizzle.team/docs/rls
      - https://neon.com/docs/guides/multitenancy
      - https://neon.com/docs/introduction/plans
      - https://developers.cloudflare.com/d1/platform/limits/
      
    • limits-and-quotas.md 5.4 KB
      # Platform limits and plan mapping
      
      Applies to step 8. Copy the rows that shape your plans into the limits-to-plan table with the source URL and access date.
      
      ## Freshness policy
      
      - Snapshot date: 2026-09-01.
      - Re-check each source before pricing, launch, or an enforcement change; the docs win on conflict and this file gets the correction.
      - Vendors rename products (Edge Config became Global Config in 2026) and move docs (`vercel.com/docs/multi-tenant/*` now lives under `/docs/platforms/multi-tenant-platforms/`); follow redirects and update URLs here.
      
      ## Cloudflare
      
      | Limit | Free | Paid | Notes |
      |-------|------|------|-------|
      | Workers CPU time per request | 10 ms | 30 s default, 5 min max | Workers for Platforms user Workers: 30 s per invocation, 15 min per Cron or Queue invocation |
      | Memory per isolate | 128 MB | 128 MB | |
      | Worker size (compressed) | 3 MB | 10 MB | |
      | Subrequests per invocation | 50 | 10,000 | Redirect hops count |
      | Workers per account | 100 | 500 | Not applied to user Workers in a dispatch namespace (unlimited) |
      | Routes per zone | 1,000 | 1,000 | Why the dispatch Worker uses one `*/*` route |
      | Custom Domains (Workers) per zone | 100 | 100 | Distinct from Cloudflare for SaaS custom hostnames |
      | Cloudflare for SaaS custom hostnames | 100 included, then $0.10 per hostname per month, cap 50,000 | Same on Pro and Business; Enterprise unlimited (talk to sales above 50,000) | Wildcard hostnames, CA choice, custom certificates, apex proxying, BYOIP: Enterprise |
      | Workers for Platforms subscription | n/a | $25 per month: 20M requests (+$0.30 per extra million), 60M CPU-ms (+$0.02 per extra million), 1,000 scripts (+$0.02 per extra script) | Per-script custom limits via `cpuMs` and `subRequests` |
      | D1 databases per account | 10 | 50,000 | Database-per-tenant ceiling |
      | D1 database size | 500 MB | 10 GB | Per database |
      | D1 queries per Worker invocation | 50 | 1,000 | 30 s per statement |
      | KV consistency | Eventual, up to 60 s | Eventual, up to 60 s | Negative lookups cached (default `cacheTtl` 60 s) |
      | Tags per script | 8 | 8 | |
      
      ## Vercel
      
      | Limit | Hobby | Pro | Enterprise | Notes |
      |-------|-------|-----|------------|-------|
      | Domains per project | 50 | Unlimited (soft 100,000) | Unlimited (soft 1,000,000) | Soft limits raised on request |
      | Domain API rate limits (per team) | 100 additions/h, 50 verifications/h, 100 removals/h | same | same | Queue onboarding; back off on `rate_limit_exceeded` |
      | Wildcard domains | Yes | Yes | Yes | Vercel nameservers required (DNS-01) |
      | Multi-tenant preview URLs on your domain | No | No | Yes | `tenant---branch-project.vercel.app` works everywhere |
      | Custom SSL certificate upload | No | No | Yes | |
      | Global Config store size | 1 MB | 1 MB | 1 MB | Formerly Edge Config (8/64/512 KB) |
      | Global Config stores | 1 total, 1 per project | Unlimited, 3 per project | Unlimited, 3 per project | |
      | Global Config writes | 250 per month | 100 per hour | 100 per hour | Write propagation up to 10 s |
      | Deployments per day | 100 | 6,000 | 24,000 | |
      | Routing Middleware request limits | URL 14 KB, body 4 MB, 64 headers, 16 KB headers | same | same | Applies to `proxy.ts` |
      | Edge Requests included | Plan allotment | First 10,000,000 | Contract | Regional pricing beyond |
      | ISR reads and writes | Plan allotment | $0.0004 per 1K reads, $0.004 per 1K writes | Contract | Per-tenant ISR pages multiply reads |
      | DNS label length | 63 chars | 63 chars | 63 chars | Preview URL tenant labels |
      
      ## Neon (database-per-tenant)
      
      | Limit | Free | Launch | Scale |
      |-------|------|--------|-------|
      | Projects included | 100 | 100 | 1,000 (soft) |
      | Scale to zero | After 5 min, always on | After 5 min, can be disabled | Configurable, 1 min to always on |
      
      ## Planning guidance
      
      - Enforce every plan limit at the routing layer (Cloudflare `limits`, Vercel `x-tenant-plan` plus server checks) and expose the same numbers in the API and the billing UI; a limit that only the UI knows about is unenforced.
      - Keep request work short on both platforms; queue or schedule anything longer than a page render (Cloudflare Queues and Workflows, Vercel background functions and cron).
      - Durable state lives in storage (D1, Neon, R2, Blob), never in-memory across requests.
      - Vercel: Global Config holds the hostname map only; everything else reads from the database. Hobby's 250 writes per month rules it out for high-churn onboarding.
      - Cloudflare: a D1 database is a single writer; shard busy tenants or give them their own database.
      - Pricing inputs that surprise teams: $0.10 per Cloudflare custom hostname past 100; per-script fees past 1,000 user Workers; Vercel ISR reads scaling with tenant page count.
      
      ## Sources
      
      Accessed 2026-09-01.
      
      - https://developers.cloudflare.com/workers/platform/limits/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/limits/
      - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/pricing/
      - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/plans/
      - https://developers.cloudflare.com/d1/platform/limits/
      - https://developers.cloudflare.com/kv/concepts/how-kv-works/
      - https://vercel.com/docs/platforms/multi-tenant-platforms/limits
      - https://vercel.com/docs/limits
      - https://vercel.com/docs/global-config/global-config-limits
      - https://vercel.com/docs/global-config/migration-guide
      - https://vercel.com/docs/routing-middleware
      - https://neon.com/docs/introduction/plans
      
    • psl.md 3.4 KB
      # Public Suffix List (PSL): decision and submission
      
      Applies to step 1 when tenants publish content or run code on sibling subdomains of your tenant domain.
      
      ## What listing does
      
      - Browsers treat every label under a listed suffix as a separate site: cookies with `Domain=<suffix>` are rejected, `SameSite` boundaries fall between tenants, and one tenant cannot set a cookie that reaches another tenant or your dashboard.
      - Isolation only. It confers no trust, reputation, or Safe Browsing separation; a phishing tenant still damages the registrable domain, which is why tenants live on their own domain regardless.
      
      ## Decide
      
      - Submit when tenants can publish HTML or JavaScript, or run code, on `<tenant>.<suffix>`.
      - Submit the label directly above the tenant name: `acme.app` for `<tenant>.acme.app`, `sites.acme.app` for `<tenant>.sites.acme.app`.
      - Not needed for custom domains tenants own, or when only your code runs on the subdomains.
      - Listing changes behavior you may rely on: parent-scoped cookies, cross-subdomain sign-in, and code that infers "same site" from the hostname. Test those before the PR, because the change lands on the browsers' schedule, not yours.
      
      ## Eligibility (PRIVATE section)
      
      - Only the domain owner or an authorized representative may submit; third-party requests are declined.
      - Registration must have more than two years remaining, with a commitment to keep more than a year on the term.
      - Declined: short-term, sandbox, or lab projects; entries meant to dodge rate limits or vendor protections; wildcard entries used for IP mapping; alternative TLD systems.
      
      ## Submission steps
      
      1. Create a permanent `TXT` record at `_psl.<suffix>` whose value is the pull request URL (add it once the PR exists). It stays in the zone after merge to signal continued inclusion.
      2. Open a PR against `publicsuffix/list` adding the suffix under `// ===BEGIN PRIVATE DOMAINS===`, with the header:
      
         ```text
         // Acme : https://acme.app/
         // Submitted by Jane Doe <jane@acme.app>
         acme.app
         ```
      
         Sort the block by company name; within it, by TLD then the label left of the TLD; keep multiple suffixes alphabetical.
      3. Describe the service, example tenant hostnames, and the intended site boundaries in the PR template. Respond to maintainer review.
      4. After merge, wait: there is no SLA and no way to expedite. Chrome and Firefox ship the list with releases; platforms that embed it in the OS update with the OS.
      
      ## Interim controls (before the list propagates)
      
      - Dashboard and auth on a different apex (`app.acme.com`) than tenant subdomains (`*.acme.app`).
      - Session cookies as `__Host-session=...; Secure; HttpOnly; Path=/; SameSite=Lax` with no `Domain` attribute: browsers reject a `__Host-` cookie that carries `Domain`, so a sibling tenant cannot overwrite it.
      - Validate `Origin` or use CSRF tokens on state-changing requests; `__Host-` does not change `SameSite` semantics.
      
      ## Record in the output
      
      `PSL decision: Submit` with suffix, owner, PR link, and the `_psl` TXT date; or `No PSL` with the reason (tenant-owned domains only, or no tenant-controlled content on subdomains).
      
      ## Sources
      
      Accessed 2026-09-01.
      
      - https://publicsuffix.org/learn/
      - https://publicsuffix.org/submit/
      - https://github.com/publicsuffix/list/wiki/Guidelines
      - https://vercel.com/docs/platforms/multi-tenant-platforms/configuring-domains (Protecting tenant subdomains with the Public Suffix List)
      - https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie
      
    • vercel-domains.md 6.8 KB
      # Vercel domain management (custom domains + SSL)
      
      Applies to step 7 when Vercel is the chosen platform: the onboarding lifecycle a tenant walks through, and the platform behaviors that break it.
      
      ## Contents
      
      - Onboarding lifecycle
      - DNS targets
      - SDK surface, error codes, rate limits
      - Ownership verification
      - Wildcard domains
      - SSL certificates
      - Redirects and canonical hosts
      - Preview URLs
      - Troubleshooting
      - Sources
      
      ## Onboarding lifecycle
      
      1. Tenant submits `tenant.com` in your UI or API.
      2. `projectsAddProjectDomain(vercel, { idOrName, teamId, requestBody: { name } })` attaches it to the project; Vercel starts certificate issuance as soon as DNS resolves to it.
      3. Show the DNS instructions from the API response for this project (`projectsGetProjectDomain` returns `verification` records when needed; `domainsGetDomainConfig` reports `misconfigured`).
      4. Tenant sets the records. Poll `projectsVerifyProjectDomain` on user action or a slow schedule; it is rate limited to 50 per hour per team.
      5. When `verified` is true and the config is no longer misconfigured, write the mapping to the database, then to Global Config.
      6. Offboarding: `projectsRemoveProjectDomain` (detach from the project) then `domainsDeleteDomain` (drop from the account); delete the mapping first so traffic 404s instead of hitting a stale tenant.
      
      ## DNS targets
      
      - Apex: `A` record. The classic value is `76.76.21.21`, but newer projects receive pool addresses such as `216.198.79.1`. Show the value the API or domain card returns for this project rather than a hardcoded IP.
      - Subdomain (`www.tenant.com`, `docs.tenant.com`): `CNAME` to the project-specific target, formatted like `d1d4fc829fe7bc7c.vercel-dns-017.com.`; the trailing period marks an absolute name and some providers require it verbatim.
      - A `CNAME` at the apex violates RFC 1034 when `NS` or `MX` records exist there; tell tenants to use the `A` record or a provider with CNAME flattening.
      - IPv6 (`AAAA`) is not supported for third-party domains.
      - Nameservers (`ns1.vercel-dns.com`, `ns2.vercel-dns.com`) work for any domain and are mandatory for wildcards; the tenant must recreate their MX and other records inside Vercel DNS.
      
      ## SDK surface, error codes, rate limits
      
      - `@vercel/sdk` functional imports: `projectsAddProjectDomain`, `projectsGetProjectDomain`, `projectsVerifyProjectDomain`, `projectsRemoveProjectDomain`, `domainsDeleteDomain`, `domainsGetDomainConfig`. Class form: `vercel.projects.addProjectDomain(...)`, `vercel.domains.getDomainConfig(...)`.
      - Error codes: `domain_already_in_use` (another project or account holds it: verify with the TXT record), `invalid_domain` (format, punycode needed for IDNs), `forbidden` (token scope or team), `rate_limit_exceeded` (back off exponentially).
      - Rate limits for platforms: 100 domain additions per hour, 50 verifications per hour, 100 removals per hour, per team. Queue onboarding jobs and never verify in a tight loop.
      - Plan caps: Hobby 50 domains per project; Pro and Enterprise unlimited with soft limits of 100,000 and 1,000,000 (raised on request).
      
      ## Ownership verification
      
      - Required only when the domain is already in use on another Vercel account or project. It grants use in your project without moving the domain.
      - Record: `TXT` at `_vercel.<tenant apex>` with the value from the API; check with `dig TXT _vercel.tenant.com`. No trailing dot in the value, no duplicate `_vercel` records, allow 5 to 10 minutes.
      - Re-verify after nameserver changes or a domain transfer.
      
      ## Wildcard domains
      
      - Point the apex's nameservers to Vercel, add the apex, then add `*.acme.app`. All plans.
      - Vercel issues a certificate per subdomain on demand using DNS-01; that is why nameservers are mandatory. Without them the wildcard shows `Invalid Configuration` and never gets a certificate.
      - Multi-level names (`docs.tenant1.acme.app`) resolve under the same wildcard.
      
      ## SSL certificates
      
      - Let's Encrypt for every domain. Non-wildcard: HTTP-01, answered by Vercel as long as the domain points at Vercel. Wildcard: DNS-01 through Vercel nameservers.
      - `CAA`: if the tenant has any `CAA` records, they must include `0 issue "letsencrypt.org"` or issuance fails. Check with `dig -t CAA +noall +ans tenant.com`.
      - A stale `_acme-challenge` TXT from a previous host blocks issuance; ask the tenant to remove it (`dig -t TXT _acme-challenge.tenant.com`).
      - `/.well-known` is reserved: it cannot be rewritten or redirected, and the proxy must let it through.
      - Renewal is automatic. Uploading custom certificates is Enterprise only.
      
      ## Redirects and canonical hosts
      
      - Add both `tenant.com` and `www.tenant.com`; set `redirect` on the secondary through the API or dashboard.
      - When a tenant serves on both `tenant.acme.app` and `tenant.com`, redirect one to the other or set `alternates.canonical` in `generateMetadata`; keep one host in the sitemap. The `seo` skill owns the canonical and sitemap content.
      - Use `308` for permanent host consolidation (preserves method), `307` for temporary.
      
      ## Preview URLs
      
      - Default pattern `tenant---branch-project.vercel.app`; split the hostname on `---` to recover the tenant.
      - Multi-tenant preview URLs on your own domain (`tenant1---project-git-branch.acme.dev`) are Enterprise only and enabled by your account representative.
      - Each DNS label is limited to 63 characters; keep branch names short or previews stop resolving.
      
      ## Troubleshooting
      
      - Nameserver changes propagate in up to 24 to 48 hours; record changes follow the old TTL. Lower the TTL to 60 s before cutover so a rollback is fast.
      - `Invalid Configuration`: wrong or missing records, verification pending, a `CAA` blocking issuance, or a wildcard without Vercel nameservers.
      - Verification failing with the record present: value mismatch, trailing dot in the value, duplicate records, or checking before propagation.
      - Tenant's DNS is Cloudflare-proxied (orange cloud) in front of Vercel: HTTP-01 and redirects then pass through Cloudflare's TLS and rule layers. Ask the tenant to set the record to DNS-only, or accept that certificates and redirects are now governed by their zone settings.
      - Same content on two hosts: canonical or redirect, and one host in the sitemap.
      - Diagnostics: `letsdebug.net` for issuance, `dnsviz.net` for DNS and DNSSEC, `whatsmydns.net` for propagation.
      
      ## Sources
      
      Accessed 2026-09-01.
      
      - https://vercel.com/docs/platforms/multi-tenant-platforms/configuring-domains
      - https://vercel.com/docs/platforms/multi-tenant-platforms/quickstart
      - https://vercel.com/docs/platforms/multi-tenant-platforms/reference
      - https://vercel.com/docs/platforms/multi-tenant-platforms/limits
      - https://vercel.com/docs/domains/working-with-domains/add-a-domain
      - https://vercel.com/docs/domains/working-with-ssl
      - https://vercel.com/docs/domains/troubleshooting
      - https://vercel.com/kb/guide/a-record-and-caa-with-vercel
      - https://vercel.com/docs/limits (Domains and Rate limits sections)
      - https://github.com/vercel/sdk
      
    • vercel-platform.md 8.6 KB
      # Vercel platform primitives (Next.js multi-tenancy)
      
      Applies to steps 4 to 7 when Vercel is the chosen platform. Domain onboarding and SSL live in the domains reference.
      
      ## Contents
      
      - Starter kit facts (what the template actually does)
      - Proxy tenant resolution
      - App Router layout
      - Global Config for the hot path
      - Per-tenant static files
      - Custom subpaths
      - Caching per tenant
      - Local development and preview URLs
      - Sources
      
      ## Starter kit facts (what the template actually does)
      
      `github.com/vercel/platforms` as of 2026-09: Next.js 16 App Router, React 19, Tailwind 4, shadcn/ui, Upstash Redis with keys `subdomain:{name}` (`KV_REST_API_URL`, `KV_REST_API_TOKEN`). `proxy.ts` extracts the subdomain (handles `*.localhost`, `tenant---branch.vercel.app` previews, and `*.<rootDomain>`), blocks `/admin` on subdomains, and rewrites `/` to `/s/{subdomain}`. Its matcher `'/((?!api|_next|[\\w-]+\\.\\w+).*)'` skips every root file with an extension, which is why the template does not serve per-tenant `robots.txt`. Treat it as a routing demo, not a data-isolation reference: it stores no tenant data beyond the subdomain record.
      
      ## Proxy tenant resolution
      
      Next.js 16 renamed `middleware.ts` to `proxy.ts` (exported function `proxy`, Node.js runtime, setting `runtime` throws). On Next.js 15 keep `middleware.ts`, export `middleware`, and add `runtime: 'nodejs'` to `config` so database clients work. Migrate with `npx @next/codemod@canary middleware-to-proxy .`.
      
      ```ts
      // proxy.ts
      import { type NextRequest, NextResponse } from "next/server";
      import { get } from "@vercel/global-config";
      
      const ROOT = process.env.NEXT_PUBLIC_ROOT_DOMAIN!; // acme.app
      const TENANT_HEADERS = ["x-tenant-id", "x-tenant-slug", "x-tenant-plan"];
      const keyFor = (hostname: string) => hostname.replace(/\./g, "_"); // Global Config keys: ^[\w-]+$
      
      function lookupKey(host: string): string | null {
        const hostname = host.split(":")[0];
        if (hostname.endsWith(".localhost")) return `sub_${hostname.split(".")[0]}`;
        if (hostname.includes("---") && hostname.endsWith(".vercel.app")) return `sub_${hostname.split("---")[0]}`;
        if (hostname === ROOT || hostname === `www.${ROOT}`) return null; // brand site
        if (hostname.endsWith(`.${ROOT}`)) return `sub_${hostname.slice(0, -(ROOT.length + 1))}`;
        return `domain_${keyFor(hostname)}`; // custom domain
      }
      
      export async function proxy(request: NextRequest) {
        const { pathname } = request.nextUrl;
        const headers = new Headers(request.headers);
        for (const h of TENANT_HEADERS) headers.delete(h); // clients never supply tenant context
      
        if (pathname.startsWith("/.well-known")) return NextResponse.next({ request: { headers } });
      
        const key = lookupKey(request.headers.get("host") ?? "");
        if (!key) return NextResponse.next({ request: { headers } });
      
        const tenant = await get<{ id: string; slug: string; plan: string }>(key);
        if (!tenant) return new NextResponse("Not found", { status: 404 }); // never fall through to brand content
      
        headers.set("x-tenant-id", tenant.id);
        headers.set("x-tenant-slug", tenant.slug);
        headers.set("x-tenant-plan", tenant.plan);
      
        const url = request.nextUrl.clone();
        url.pathname = `/s/${tenant.slug}${pathname}`; // robots.txt, sitemap.xml, llms.txt included
        return NextResponse.rewrite(url, { request: { headers } });
      }
      
      export const config = {
        matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
      };
      ```
      
      - Request headers, not response headers: `NextResponse.next({ headers })` ships them to the browser and `headers()` never sees them.
      - The proxy runs for `_next/data` even when excluded, and a matcher that excludes a path also skips Server Function POSTs on it. Re-derive the tenant in Server Functions from the session and enforce in the data layer.
      - Avoid large headers; some origins return `431` above a few KB.
      
      ## App Router layout
      
      - `app/(brand)/`: marketing and console on the apex.
      - `app/s/[slug]/layout.tsx`: tenant branding (logo, theme, fonts) from the database, `generateMetadata` with `metadataBase` set to the tenant's canonical host and `alternates.canonical` when a tenant serves on both a subdomain and a custom domain.
      - `app/s/[slug]/[[...path]]/page.tsx`: tenant pages.
      - `app/s/[slug]/robots.txt/route.ts`, `sitemap.xml/route.ts`, `llms.txt/route.ts`: per-tenant files (below).
      - Reading tenant context: `params.slug` for cache keys and data fetching; `(await headers()).get("x-tenant-plan")` for plan gating; `request.headers.get("x-tenant-id")` in route handlers.
      
      ## Global Config for the hot path
      
      Edge Config was renamed Global Config. Package `@vercel/global-config` (drop-in for `@vercel/edge-config`), env var `GLOBAL_CONFIG` (legacy `EDGE_CONFIG` still read by the new SDK; the legacy SDK cannot read newly connected stores).
      
      - Store only `hostname -> { id, slug, plan }`. 1 MB per store on every plan, 3 stores per project, up to 10 s write propagation, writes 250 per month on Hobby and 100 per hour on Pro and Enterprise. Onboarding many domains on Hobby exhausts the write quota; the database stays the source of truth and Global Config is a write-through cache.
      - Key names match `^[\w-]+$` (256 chars): encode dots in hostnames.
      - Prefer `getAll()` over several `get()` calls; each SDK call is one billable read.
      - The confirmation screen after a domain verifies reads the database, not Global Config, because of the propagation window.
      
      ## Per-tenant static files
      
      Route handlers inside the tenant segment, reached through the rewrite above:
      
      ```ts
      // app/s/[slug]/robots.txt/route.ts
      import { NextResponse } from "next/server";
      
      export async function GET(_: Request, { params }: { params: Promise<{ slug: string }> }) {
        const { slug } = await params;
        const tenant = await getTenantBySlug(slug);
        if (!tenant) return new NextResponse("Not found", { status: 404 });
        const body = `User-agent: *\nAllow: /\nSitemap: https://${tenant.primaryHost}/sitemap.xml\n`;
        return new NextResponse(body, {
          headers: { "Content-Type": "text/plain", "CDN-Cache-Control": "s-maxage=3600" },
        });
      }
      ```
      
      - `Content-Type`: `text/plain` for `.txt`, `application/xml` for `sitemap.xml`.
      - `CDN-Cache-Control` caches at the Vercel CDN independent of the browser; purge by tag or path when tenant content changes.
      - `/public` is for files identical across tenants; large media goes to Blob storage.
      
      ## Custom subpaths
      
      Platform content under a customer path (`customer.com/docs`) while the customer hosts the rest of their site:
      
      - Catch-all `app/sites/[...slug]/page.tsx` with `[customerSlug, ...contentPath]`.
      - `assetPrefix: '/your-platform-assets'` plus a rewrite `/your-platform-assets/_next/:path*` -> `/_next/:path*`, so the customer only proxies two prefixes: `/docs/:path*` -> `https://acme.app/sites/<slug>/:path*` and `/your-platform-assets/:path*` -> `https://acme.app/your-platform-assets/:path*`.
      - Subdomain traffic can rewrite into the same path routes (`tenant.acme.app/guide` -> `/sites/tenant/guide`) so one route tree serves both.
      
      ## Caching per tenant
      
      - Next.js 16 Cache Components: `'use cache'` with `cacheTag(\`tenant-${id}\`)`; invalidate with `revalidateTag`. On Next.js 15, `unstable_cache` with `tags`.
      - Every cache key includes the tenant id (function argument or tag); a cached tenant layout without it serves one tenant's branding to another.
      - ISR serves stale while revalidating; per-tenant `generateMetadata` and OG images key on the tenant too.
      
      ## Local development and preview URLs
      
      - Chromium and Firefox resolve `*.localhost` to loopback without `/etc/hosts`; Safari and `curl` need entries or `curl --resolve tenant1.localhost:3000:127.0.0.1`. HTTP only locally.
      - Preview deployments: `tenant---branch-project.vercel.app`, parsed by splitting on `---`. Multi-tenant preview URLs on your own domain (`tenant1---project-git-branch.acme.dev`) are Enterprise only.
      - Each DNS label is capped at 63 characters, so long branch names plus a tenant label fail to resolve.
      
      ## Sources
      
      Accessed 2026-09-01.
      
      - https://vercel.com/docs/platforms
      - https://vercel.com/docs/platforms/multi-tenant-platforms/concepts
      - https://vercel.com/docs/platforms/multi-tenant-platforms/middleware-and-routing
      - https://vercel.com/docs/platforms/multi-tenant-platforms/serving-static-files
      - https://vercel.com/docs/platforms/multi-tenant-platforms/custom-subpaths
      - https://vercel.com/docs/platforms/multi-tenant-platforms/limits
      - https://vercel.com/docs/platforms/examples/multi-tenant-template
      - https://vercel.com/docs/platforms/multi-project-platforms/concepts
      - https://github.com/vercel/platforms (proxy.ts, README)
      - https://nextjs.org/docs/app/api-reference/file-conventions/proxy
      - https://vercel.com/docs/global-config/global-config-limits
      - https://vercel.com/docs/global-config/migration-guide
      - https://vercel.com/docs/routing-middleware
      
  • SKILL.md 15.8 KB
    ---
    name: multi-tenant-architecture
    description: Designs tenant isolation, hostname routing, custom-domain lifecycle, and plan limits on Cloudflare or Vercel. Use when asked to "isolate tenant data", "support custom domains", "build a white-label platform", or assess PSL registration. For general module structure use codebase-architecture; for SEO content use seo.
    ---
    
    # Multi-Tenant Platform Architecture (Cloudflare or Vercel)
    
    - **IS:** platform choice, domain strategy and PSL, tenant identification, compute and data isolation, hostname routing, tenant context propagation, custom domains and SSL, per-tenant static files, and mapping platform limits to plans.
    - **IS NOT:** general folder structure or module contracts (use `codebase-architecture`), scaffolding a new repo (use `scaffold-nextjs`), or the content of per-tenant SEO files once routing serves them dynamically: sitemap entries, canonical URLs, structured data, indexing policy (use `seo`).
    
    ## Contents
    
    - Platform dispatch (decide first)
    - Reference files
    - Workflow (order matters)
    - Gotchas
    - Output schema
    - Pre-commit checklist
    - Related skills
    
    ## Platform dispatch (decide first)
    
    | Signals | Platform | Model |
    |---------|----------|-------|
    | Tenants upload or generate their own code; code-level isolation; edge compute on KV, D1, Durable Objects, R2 | Cloudflare | Dispatch Worker in front of a dispatch namespace of per-tenant Workers; Cloudflare for SaaS for custom hostnames |
    | Every tenant runs the same Next.js codebase and differs by content, branding, and plan; ISR, Server Components, Vercel deploys | Vercel | One deployment; `proxy.ts` resolves the tenant from the hostname; wildcard plus custom domains on the project |
    
    - Pick one platform per product. Fronting a Vercel app with a Cloudflare proxy doubles the TLS and redirect layers and is the usual cause of redirect loops and failed certificate issuance.
    - Tenants shipping their own code on Vercel is the multi-project model (one Vercel project per tenant, created with the SDK). It follows the Cloudflare row's isolation reasoning; this skill's Vercel references cover the single-deployment model only.
    
    ## Reference files
    
    | File | Read when |
    |------|-----------|
    | [cloudflare-platform.md](references/cloudflare-platform.md) | Cloudflare chosen: dispatch namespaces, routing, Cloudflare for SaaS custom hostnames, isolation modes, KV and D1 (steps 3 to 7) |
    | [vercel-platform.md](references/vercel-platform.md) | Vercel chosen: `proxy.ts` resolution, App Router layout, Global Config lookups, per-tenant static files, custom subpaths, local dev (steps 4 to 7) |
    | [vercel-domains.md](references/vercel-domains.md) | Vercel chosen: SDK domain lifecycle, DNS targets, verification, wildcard nameservers, SSL, troubleshooting (step 7) |
    | [data-isolation.md](references/data-isolation.md) | Step 3 on either platform: shared schema with RLS, schema-per-tenant, database-per-tenant, and the Postgres/Supabase/Drizzle policy pattern |
    | [psl.md](references/psl.md) | Step 1 when tenants publish content or run code on sibling subdomains: eligibility, submission, interim cookie controls |
    | [limits-and-quotas.md](references/limits-and-quotas.md) | Step 8: dated snapshot of Cloudflare, Vercel, and Neon limits to map onto plans |
    | `agents/openai.yaml` | Never during a task: launcher metadata for external runners |
    
    ## Workflow (order matters)
    
    Copy this checklist to track progress:
    
    ```text
    Multi-tenant progress:
    - [ ] Step 1: Domain strategy and PSL decision
    - [ ] Step 2: Tenant identification strategy
    - [ ] Step 3: Isolation model (compute and data)
    - [ ] Step 4: Deterministic routing
    - [ ] Step 5: Tenant context propagation
    - [ ] Step 6: Tenant config and least-privilege bindings
    - [ ] Step 7: Custom domains and per-tenant static files
    - [ ] Step 8: Limits mapped to plans, evidence captured
    ```
    
    1. Choose the domain strategy
    - Put tenant workloads on a dedicated registrable domain (`acme.app` for tenants, `acme.com` for brand). One phishing tenant on `x.acme.com` puts the whole domain on blocklists, and a tenant cookie with `Domain=acme.com` reaches your dashboard.
    - Keep the dashboard and auth on a different apex from tenant subdomains (`app.acme.com` for the console, `*.acme.app` for tenants).
    - If tenants publish content or run code on sibling subdomains, submit the label directly above the tenant name (`acme.app`, or `sites.acme.app` for `<tenant>.sites.acme.app`) to the PSL and start now: there is no SLA. Tenant-owned custom domains need no PSL entry. Otherwise record `No PSL` with the reason.
    
    2. Choose tenant identification (one primary; custom domain as the upgrade path)
    - **Subdomain** `tenant.acme.app`: wildcard DNS plus wildcard certificate. The default.
    - **Custom domain** `tenant.com`: the tenant CNAMEs to you. Paying tenants; reputation shifts to them; needs the onboarding lifecycle in step 7.
    - **Path** `acme.app/tenant`: no per-tenant DNS or certificates, but no cookie isolation and no branding. Choose it only when tenants will never get a hostname.
    
    3. Define the isolation model
    - **Compute, Cloudflare:** one dispatch namespace in untrusted mode; per-invocation `cpuMs` and `subRequests` limits per plan; an outbound Worker if tenant code may call the internet.
    - **Compute, Vercel:** one deployment, tenant code never executes. If tenants must ship code, move to Vercel multi-project or Cloudflare rather than sandboxing inside the app.
    - **Data:** shared schema with `tenant_id` on every tenant-aware table plus RLS is the default; database-per-tenant for regulated or noisy tenants, selectable per plan. See [data-isolation.md](references/data-isolation.md).
    
    4. Route deterministically (tenants never influence routing or see each other)
    - **Cloudflare:** a single `*/*` route on the SaaS zone to the dispatch Worker; hostname -> tenant record (KV, D1 on miss) -> `env.DISPATCHER.get(script)`; `Worker not found` -> 404.
    - **Vercel:** `proxy.ts` (Next.js 16; `middleware.ts` with `runtime: 'nodejs'` on 15) reads `host`, looks the tenant up in Global Config or the database, rewrites into the tenant segment; unknown hostname -> 404, never the brand site.
    - Let `/.well-known` through before any tenant rewrite. Route `robots.txt`, `sitemap.xml`, and `llms.txt` into the tenant segment so they vary per tenant.
    
    5. Propagate tenant context from one authority
    - Delete every inbound `x-tenant-*` header, set `x-tenant-id`, `x-tenant-slug`, `x-tenant-plan` from the resolved tenant, and forward them on the request (`NextResponse.next({ request: { headers } })`). Server Components read `await headers()`; route handlers read `request.headers`. Cloudflare: the dispatch Worker sets headers or passes parameters before `fetch`.
    - The proxy is routing, not authorization. Server Functions, route handlers, and jobs re-derive the tenant from the session and the data layer enforces it (RLS or `tenant_id` predicates).
    
    6. Bind only what the tenant needs
    - **Cloudflare:** each user Worker gets its own bindings (KV namespace, D1 database, R2 prefix); adding a binding is an explicit redeploy. No shared globals.
    - **Vercel:** Global Config holds only `hostname -> { id, slug, plan }`; the database is the source of truth and write-through happens when a domain verifies. Feature flags and branding come from the database keyed by tenant id.
    
    7. Support custom domains and per-tenant static files
    - Lifecycle to design and record: add domain -> show DNS target -> verify ownership -> certificate issued -> mapping activated -> removal or failure path.
    - **Cloudflare:** Cloudflare for SaaS custom hostname on the SaaS zone, proxied fallback origin, `customers.<you>.com` CNAME target, `http` or `txt` validation, pre-validate before DNS cutover. See [cloudflare-platform.md](references/cloudflare-platform.md).
    - **Vercel:** `projectsAddProjectDomain` -> DNS values from the project's domain card -> `_vercel` TXT only if the domain is already on Vercel -> `projectsVerifyProjectDomain` -> Let's Encrypt HTTP-01. See [vercel-domains.md](references/vercel-domains.md).
    - `robots.txt`, `sitemap.xml`, `llms.txt` are route handlers inside the tenant segment with explicit `Content-Type`; nothing tenant-specific lives in `/public`. Their content is `seo` territory.
    
    8. Surface limits as plans and capture evidence
    - Fill the limits-to-plan table from [limits-and-quotas.md](references/limits-and-quotas.md), re-checking each source URL and dating it; enforce at the routing layer (Cloudflare `limits`, Vercel plan header plus server checks).
    - Nothing long-running in the request path: Cloudflare Queues or Workflows, Vercel background functions or cron.
    - Every tenant operation (create tenant, add domain, verify, remove) works over HTTP with the same authority as the UI; if it only works in the dashboard, the platform leaks into the UI.
    - Run the evidence commands in the pre-commit checklist and paste results into the output.
    
    ## Gotchas
    
    - Tenant headers set on the response instead of the request: `NextResponse.next({ headers })` sends `x-tenant-id` to the browser and `headers()` in Server Components reads nothing. Use `NextResponse.next({ request: { headers: requestHeaders } })`.
    - Forwarding inbound tenant headers: `curl -H "x-tenant-id: <other>"` then serves another tenant's data. Delete or overwrite `x-tenant-*` on every path through the proxy, including paths that skip resolution.
    - The starter kit matcher `'/((?!api|_next|[\\w-]+\\.\\w+).*)'` excludes every root file with an extension, so `robots.txt` and `sitemap.xml` skip the proxy and every tenant gets the platform's `/public` copy. Match them, and rewrite them into the tenant segment.
    - Next.js 16 renamed `middleware.ts` to `proxy.ts` (export `proxy`, Node.js runtime, a `runtime` config option throws). `npx @next/codemod@canary middleware-to-proxy .` migrates. A matcher that excludes a path also skips Server Function POSTs on it, so tenant checks live in the data layer too.
    - Global Config (formerly Edge Config) key names must match `^[\w-]+$`; `tenant_acme.com` is rejected. Use a collision-free encoding or hash; replacing dots with underscores can map different hostnames to the same key. Writes propagate in up to 10 s, so a "domain connected" screen that reads Global Config right after the write shows stale state; read the database there. The legacy `@vercel/edge-config` SDK cannot read stores connected after the rename (they create `GLOBAL_CONFIG`, not `EDGE_CONFIG`).
    - RLS is bypassed by superusers and `BYPASSRLS` roles; table owners bypass it unless `FORCE ROW LEVEL SECURITY` is enabled. An app connecting as the migration role sees every tenant with policies "on". Connect as a separate role, add `ALTER TABLE ... FORCE ROW LEVEL SECURITY`, and test with `SET ROLE app_user`.
    - `SET app.tenant_id = ...` outside a transaction on a pooled connection persists into the next request. Use `set_config('app.tenant_id', $1, true)` inside the transaction; with PgBouncer in transaction mode it is the only safe form.
    - Wildcard `*.acme.app` on Vercel without Vercel nameservers never gets a certificate: DNS-01 needs Vercel to write `_acme-challenge`. Point `ns1.vercel-dns.com` and `ns2.vercel-dns.com` first and re-add MX records.
    - `/.well-known` is reserved on Vercel and cannot be rewritten or redirected; a proxy that rewrites every path into `/s/[slug]` breaks HTTP-01 and custom-domain certificates never issue. Pass it through first.
    - Cloudflare for SaaS: the fallback origin must be a proxied record in the SaaS zone; a custom hostname equal to the zone name is unsupported; `_cf-custom-hostname` pre-validation does not work when the customer's zone is also on Cloudflare (O2O, marked by `cf-connecting-o2o: 1`).
    - Untrusted dispatch namespaces (default) have no `request.cf` and no `caches.default`, so tenant code reading `request.cf.country` throws. Trusted mode restores them but shares one cache across every tenant Worker in the namespace.
    - KV is eventually consistent (up to 60 s, negative lookups cached): a hostname added after the dispatch Worker's first lookup 404s for a minute. Fall back to D1 on miss during onboarding.
    - PSL rejects domains with under two years of registration left; the `_psl.<suffix>` TXT stays in place after merge; browsers ship the list on their own release cycles. Listing also kills `Domain=acme.app` cookies, including your own cross-subdomain SSO if it lives there.
    - Starting path-based with custom domains on the roadmap means URL rewrites, cookie changes, and DNS migration later.
    - Domain quotas and charges vary by provider and plan. Put current official limits and their access dates in the plan table before setting pricing.
    
    ## Output schema
    
    Length follows the decisions: drop any section the project does not face rather than filling it.
    
    ```markdown
    # Multi-tenant architecture
    
    ## Platform decision
    - Platform: Cloudflare | Vercel
    - Why this platform:
    - Rejected platform and reason:
    
    ## Domain map
    - Brand domain:
    - Tenant domain:
    - Tenant subdomains:
    - Custom domains:
    - PSL decision: Submit (suffix, owner, PR link, _psl TXT date) | No PSL (reason)
    
    ## Routing matrix
    | Host pattern | Resolver | Destination | Unknown tenant behavior |
    |---|---|---|---|
    
    ## Tenant context flow
    - Authority: proxy.ts | dispatch Worker
    - Headers set and stripped:
    - Server read path:
    - Data-layer enforcement:
    
    ## Isolation model
    - Compute isolation:
    - Data isolation (and per-plan variant):
    - Config/binding isolation:
    
    ## Custom-domain lifecycle
    1. DNS target:
    2. Ownership verification:
    3. Certificate provisioning:
    4. Routing activation:
    5. Removal/failure path:
    
    ## Limits-to-plan table
    | Limit | Source URL / access date | Free | Pro | Enterprise | Enforcement point |
    |---|---|---:|---:|---:|---|
    
    ## Validation evidence
    | Check | Command | Expected | Result |
    |---|---|---|---|
    ```
    
    ## Pre-commit checklist
    
    - [ ] Platform chosen with rationale; multi-project or Cloudflare chosen if tenants ship code
    - [ ] Tenant workloads off the brand domain; dashboard on a separate apex; PSL decision recorded
    - [ ] Identification strategy chosen; custom-domain upgrade path defined
    - [ ] Isolation model defined for compute and data, including the per-plan variant
    - [ ] Routing tenant-blind: unknown host -> 404; `/.well-known` passes through; static files vary per tenant
    - [ ] Inbound `x-tenant-*` stripped; context set by the proxy or dispatch Worker only; data layer enforces tenant
    - [ ] Custom-domain lifecycle defined end to end, including removal
    - [ ] Limits table dated from official URLs; enforcement points named; long work off the request path
    
    Evidence commands (run against local or preview; mark N/A with a reason):
    
    | Check | Command | Expected |
    |---|---|---|
    | Tenant boundary exists in code | `rg -n "x-tenant-id\|CREATE POLICY\|FORCE ROW LEVEL SECURITY\|DISPATCHER.get" .` | Hits in proxy or dispatch Worker and in the schema |
    | Unknown host is 404 | `curl -sI -H "Host: nope.acme.app" <url>` | `404` |
    | Forged header ignored | `curl -s -H "Host: a.acme.app" -H "x-tenant-id: tenant-b" <url>/api/whoami` | Tenant A |
    | Static files vary | `curl -s -H "Host: a.acme.app" <url>/robots.txt` vs `-H "Host: b.acme.app"` | Different bodies, `Content-Type: text/plain` |
    | RLS holds for the app role | `psql -c "BEGIN; SET LOCAL ROLE app_user; SELECT set_config('app.tenant_id','<t1>',true); SELECT count(*) FROM posts; ROLLBACK;"` | Only tenant t1's rows |
    | ACME path reachable | `curl -sI -H "Host: tenant.com" <url>/.well-known/acme-challenge/test` | Not a redirect into the tenant segment |
    | Limits current | Access date next to each URL in the limits table | Dated within the planning window |
    
    ## Related skills
    
    - `codebase-architecture`: folder structure, module contracts, and the request-context pipeline for the application itself.
    - `scaffold-nextjs`: bootstrap the Next.js turborepo before applying these tenancy patterns.
    - `seo`: content of per-tenant `robots.txt`, `sitemap.xml`, `llms.txt`, canonical URLs, and structured data once routing serves them.
    
    Maintenance only: `evals/evals.json` contains regression scenarios for changes to this skill; it does not load during a user task.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related