All skills
mblode avatar

/multi-tenant-architecture

@36c52bf
by Matthew Blodemblode/agent-skills136 stars
12

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.

Use this Skill: https://skilld.dev/gh/mblode/agent-skills/multi-tenant-architecture

This session only. Nothing lands on disk.

referencesvercel-platform.md

≈2.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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

// proxy.ts
import { createHash } from "node:crypto";
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"];
type Tenant = { id: string; slug: string; plan: string; hostname: string };

// Edge store keys allow only [A-Za-z0-9_-]. Hash, never replace separators:
// replacing dots and hyphens with "_" maps different hostnames to one key.
const keyFor = (hostname: string) =>
  `h_${createHash("sha256").update(hostname).digest("hex")}`;

function tenantHostname(host: string): string | null {
  const hostname = host.split(":")[0].toLowerCase();
  if (hostname === ROOT || hostname === `www.${ROOT}`) return null; // brand site
  if (process.env.NODE_ENV !== "production" && hostname.endsWith(".localhost")) {
    return `${hostname.split(".")[0]}.${ROOT}`;
  }
  if (hostname.includes("---") && hostname.endsWith(".vercel.app")) {
    return `${hostname.split("---")[0]}.${ROOT}`; // preview deployments
  }
  return hostname; // tenant subdomain or 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 hostname = tenantHostname(request.headers.get("host") ?? "");
  if (!hostname) return NextResponse.next({ request: { headers } });

  // Written only after the domain verified; the database stays the source of truth.
  const tenant = await get<Tenant>(keyFor(hostname));
  if (!tenant || tenant.hostname !== hostname) {
    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 keyFor(hostname) -> { id, slug, plan, hostname }, written after the domain verifies. 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:

// 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_cachewithtags`.
  • 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.

Source: SKILL.md on GitHub

1 warning8d5 checks · Risk SAFE
  • Gen Agent Trust Hub8d

    This skill provides comprehensive architectural guidance and reference documentation for building secure multi-tenant platforms on Cloudflare and Vercel. It outlines best practices for tenant isolation, domain management, and request routing, and identifies common security pitfalls with clear remediation steps. No malicious code or patterns were detected.

  • Socket8d

    No alerts

  • Snyk8d

    Risk: LOW · No issues

  • Runlayer6mo

    6/7 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 36c52bf. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 25 minutes ago.

Activeupdated 3 weeks ago

README badge

README badge for mblode/agent-skills/multi-tenant-architecture