Architecture
Papervine runs two planes — a multi-tenant docs renderer and a control plane — that share one codebase.
Papervine runs two planes — a multi-tenant docs renderer and a control plane — that share one codebase.
Papervine is docs-as-code: the source of truth is MDX + a docs.json in your Git
repo, never the platform. The deployed app holds no tenant content at build time — content
is fetched and rendered on demand, with aggressive caching, so a new deploy never requires
rebuilding every tenant’s site. Everything below follows from that: a stateless renderer
that resolves a tenant per request, and a control plane that syncs Git into a content store
for the renderer to read.
Git repo (MDX + docs.json)
│ push webhook
▼
Dashboard ──▶ Control Plane ──▶ Content Store ──▶ Render Plane
(settings, (tenant mgmt, (Postgres, (multi-tenant
members, git sync, object store, Next.js app:
analytics) domains) cache) resolve → render)
Papervine is one Next.js codebase that exposes two surfaces. They can split into separate deployments later for scaling, but v1 is a single app with different route groups.
The public-facing app that serves every tenant’s docs site. Stateless, horizontally
scalable, reads from the content store. This is where ~99% of traffic goes. Its routes
are the docs pages plus /api/search, /api/assistant, and the playground.
The dashboard + API for tenant management, Git sync, billing, and analytics. Lower traffic, write-heavy. It owns the content pipeline that turns a push into compiled bundles the render plane can serve.
The two planes are deliberately themed apart and must never leak into each other:
docs.json
(src/lib/theme.ts, globals.css) — see config.src/styles/platform.css (scoped under .db) and applied via <PlatformShell>. Its UI
primitives are shadcn/ui mapped onto the .db palette. The
neutral tokens resolve only inside
the .db scope, so they cannot bleed into the renderer.The control plane lives on its own host (app.papervine.io). For how it is mounted and
why, and for the host-only session cookie, see the
control-plane overview and platform auth.
Next.js middleware (src/middleware.ts) inspects the Host header and rewrites
internally so the right tenant renders — without touching the database at the edge. Edge
middleware classifies by host suffix only (isPlatformHost / isAppHost); anything
that needs the DB happens downstream in a Node-runtime route.
resolveTenantSlug (src/lib/tenant-host.ts) reads the host and picks one of two serving
modes:
{slug}.{tenant-domain} → tenant by slug. Middleware rewrites to
/sites/[tenant]/[...slug]. This is the primary, production form, and needs a wildcard
domain you own plus wildcard TLS.
The apex, www, and reserved labels resolve to the platform landing and control plane —
never a tenant. The isAppHost check routes the app. host to the control plane.
Papervine serves from two separate registrable domains, and the separation is load-bearing:
| Domain | Serves | |
|---|---|---|
| Platform | papervine.io | marketing apex, and the control plane on app. |
| Tenant | papervine.page | every customer’s docs site, at {slug}.papervine.page |
The reason is cookie scope. A docs site renders customer-authored MDX, and for as long as
that ran on a subdomain of the platform domain, it shared a registrable domain with the
control plane’s cookies. The dashboard session is host-only on app., and even the benign
“signed in” hint had to be httpOnly precisely because it reached tenant subdomains. Moving
tenants to their own domain removes that exposure instead of mitigating it.
Two further consequences fall out of the split:
docs,
app, www and api are ordinary site slugs. Reserved labels still apply on the platform
domain and on *.localhost in dev, where the platform and tenants share a suffix.tenantHostFor() returns the
configured tenant domain rather than deriving one from the incoming request. Deriving would
be wrong wherever a URL is minted while serving a platform request — the dashboard’s live
links, the MCP endpoint, the widget’s citations — all of which run on app. and would
otherwise emit the wrong domain.Both domains come from NEXT_PUBLIC_PLATFORM_DOMAIN / NEXT_PUBLIC_TENANT_DOMAIN, so a
deployment can differ. Leave them unset locally: dev shares .localhost for both, which
means the split is not exercised in local development — it only becomes real in an
environment with two actual domains.
URLs minted before the split keep working. A legacy {slug}.{platform-domain} host still
resolves and is 308-redirected to its canonical home on the tenant domain, path and
query intact, so bookmarks and inbound links survive the move.
Choosing a tenant domain? Two independent protections are worth knowing apart. A TLD like
.page or .dev is HSTS-preloaded wholesale, so every host under it is HTTPS-only from
registration with nothing to submit. Isolating tenants’ cookies from each other is a
different mechanism — the Public Suffix List — and needs a per-domain submission no TLD
grants automatically.
Path mode is additive, not a downgrade: in subdomain (host) mode the base is empty, so
the rendered output is byte-identical. The path form is the fallback when there is no
wildcard domain — for example a bare *.vercel.app deploy can’t get nested TLS — and it
doubles as the no-custom-domain story. When a real domain is added, subdomain
serving lights up through the unchanged resolver.
Because content is fetched per request, the renderer scopes the active content source with
request-scoped storage. A single requestContentSource() (src/lib/request-source.ts)
resolves the tenant source from the x-papervine-site header (stamped by middleware for
both subdomain rewrites and apex path mode), or from the host. The root layout and the
page both read config inside that scope, so the sidebar (built from docs.json) and the
page body always come from the same tenant — not the platform’s default content.
The API routes need the same care: middleware does not rewrite /api/*, so
/api/search and /api/assistant resolve their source explicitly via
requestContentSource(site) and run their whole body (including streaming tool calls)
inside that scope. In path mode the request hits the apex with no tenant in the host, so the
client passes the active slug explicitly (?site= / body site). See
search and the AI assistant.
docs.example.com)There are two independent domain systems. Don’t conflate them.
Because we control the DNS for papervine.io, the host platform can auto-issue a single
wildcard TLS cert. Pointing the nameservers at the host lets it complete a DNS-01
challenge for *.papervine.io. A wildcard CNAME at a registrar is not enough — the
wildcard cert only issues when the host controls DNS.
A custom domain lives under the customer’s nameservers, which we never control, so
the wildcard trick can’t apply — each custom domain needs its own cert. The customer adds
a CNAME docs.example.com → {branded target} (apex domains use an A record, since you
can’t CNAME an apex); we attach the domain to the project; the platform issues a per-host
cert via HTTP-01, with no nameserver change from the customer.
Papervine’s own organization can point a host on the platform domain at one of its own
sites — that is the custom-domain feature, not an exception to it. What is refused is
only what is structurally ours: the apex, the www/app/api labels we actually
serve from, and anything on the tenant domain. Everything else on the platform domain is
claimable, but only by the operator (PLATFORM_ADMIN_EMAILS) — otherwise any customer
could park content on a subdomain of the platform’s own brand.
Two checks that must agree, and did not at first: parseCustomDomain decides whether the
host may be saved, and middleware decides whether it routes. Gate the second on “is
this host ours?” instead of “is this host structurally reserved?” and a claimed host saves
happily, then silently falls through to the marketing page.
Owners connect or remove a domain and choose root vs /docs hosting
(customDomainSubpath) at Settings → Domain setup. In /docs mode the route owns only
the /docs/* subtree, so the customer keeps their apex for other purposes. A custom-domain
request never goes through /sites — the slug isn’t known at the edge — so middleware
forwards the raw host (x-papervine-host) and rewrites to a dedicated
/custom-domain/[[...path]] route (Node runtime, has DB) that resolves the site or returns
notFound().
Why a branded CNAME target, not the host’s edge directly. Customers CNAME at a record
in our zone (e.g. cname.papervine.io → cname.vercel-dns.com). That indirection means we
can re-point one record on our side at migration time instead of asking every customer to
edit their DNS. The target is operator-configurable (CUSTOM_DOMAIN_CNAME_TARGET), so the
code hardcodes no operator domain — each deployment sets its own.
Attaching each customer domain to the hosting project hits a per-project domain cap (~50
on Vercel Pro). Papervine doesn’t get trapped: tenant resolution already keys off the host
header, so the platform never needs to know individual customer domains. Phase 2 (around
40–50 custom domains) fronts custom-domain traffic with a SaaS-domains proxy that issues a
cert per hostname and forwards to one origin under our wildcard, passing the real host in
X-Forwarded-Host. Because customers CNAME at the branded target, that cutover is a
zero-customer-DNS-change re-point for the CNAME majority. Build the proxy only when the cap
is in sight.
We poll until the cert verifies. A live check (GET {domain}/api/site-identity) confirms
the cert issued and that middleware maps the host to the right slug — strictly stronger
than a DNS-only verified flag — then flips the dashboard badge to Connected. From
then on, the middleware’s third branch resolves the host to a site via
getSiteByCustomDomain(host) and renders through the same renderTenantDocs() path as
everything else.