Extensions
Replace the single-site request policy, resolution, or telemetry module.
The framework serves one documentation site out of the box. Everything a host
decides per request — which site a request serves, which content version,
whether to redirect, how to tag the CDN cache, what telemetry to attach — goes
through one seam: a module exporting extensions: DocsExtensions. The types
live in src/extensions/types.ts; that file is the whole contract.
You do not need an extension to deploy. Replace the default only when you host several sites from one deployment, route by hostname, or attach your own telemetry.
Choosing the module
Set DOCS_EXTENSIONS=./extensions.ts and rebuild to select a root-level module.
The server imports #docs-extensions. vite.config.ts and
vitest.config.ts alias it to:
src/extensions/default.tswhenDOCS_EXTENSIONSis unset, or- the file
DOCS_EXTENSIONSnames, resolved against the framework’s root directory when relative.
The choice is made at build time, so set DOCS_EXTENSIONS in the build
environment. TypeScript resolves #docs-extensions to the default through
tsconfig.json paths; your module must export the same name and type:
For a local extensions.ts at the repository root, extend the working default
and add the hooks you need:
import type { DocsExtensions } from "./src/extensions/types"
import { extensions as defaultExtensions } from "./src/extensions/default"
export const extensions: DocsExtensions = {
...defaultExtensions,
telemetry: {
eventProperties(request) {
return { docsHost: new URL(request.url).host }
},
},
}With DOCS_EXTENSIONS set, the test suite resolves #docs-extensions to your
module, so only code that imports #docs-extensions exercises it; the default
extension’s own tests import src/extensions/default.ts directly.
The contract
resolveSite(request): Promise<SiteResolution>
Called once per request (the result is shared by every reader of that request). It returns:
| Field | Meaning |
|---|---|
site | The DocsSite the request serves, or null when the request names no site. Production answers null with 404; previews render an empty, uncacheable page. |
versionSelector | Which content version to serve: "current" (the site’s live pointer), { kind: "commit", sha } (an exact ingested commit) or { kind: "preview", shaPrefix } (a preview build by commit prefix; an ambiguous prefix serves nothing). |
preview | true makes every response no-store and noindex. |
canonicalHost | Production only: when set and different from the request’s host, the request is answered with a 308 to this host. null never redirects. |
The server, not the extension, applies the serving gates: a version is served
only when it is ready and the site’s status is active, read live on every
request. An extension may therefore cache site without delaying a suspension.
DocsSite carries the fields the server needs from the docs_site row: id,
slug (the public identifier used in analytics, cache tags and URLs), name,
description, repo ({ owner, name, branch, subdir } or null),
analyticsEnabled (false suppresses the site’s configured analytics
integrations) and webmcpEnabled.
requestPolicy(request): RequestPolicy
Called on every request before any database read, so it must be cheap and synchronous.
| Field | Meaning |
|---|---|
hstsPreload | true sends HSTS with preload; includeSubDomains; false sends a plain max-age. Only enable it for domains you control end to end. |
cacheTag | The Vercel-Cache-Tag for purgeable responses: at most 256 characters, no commas. null sends no tag. |
preview | Treat the request as a preview without consulting the database. |
shortCircuit | A Response to return immediately for the whole request, or null. |
telemetry
All hooks are optional.
eventProperties(request, resolution)adds properties to every analytics event.groups(resolution, request)returns an analytics group (groupType,groupKey, optionalproperties) for the event and its one-time group identify, ornull.resolutionisnullwhen the site lookup failed, so a host can still group by the request.onEvent(event)receives every observability event the framework emits.visitorKey(request, resolution)is the stable input the cookieless visitor and session ids hash alongside the address and user agent. Defaults to the served site’s slug (the request host when no site resolves).
webmcpRollout(site, posthog) (optional)
Turns WebMCP on for a site in addition to site.webmcpEnabled. It runs on
every page render and must never block: answer from a cache and refresh in the
background.
rateLimitScope(site) (optional)
The identity the public rate limiter and the feedback signature key on.
Defaults to site.id.
clientIp(request) (optional)
The caller’s address for the public rate limiters (chat, feedback), stored
only hashed. Return undefined to fall back to the default: Vercel’s
x-vercel-forwarded-for, then x-forwarded-for. A host behind another proxy
(Cloudflare, say) answers with that proxy’s client-address header — only for
requests it knows came through the proxy.
The default extension
src/extensions/default.ts serves a single site:
- Site: the
docs_siterow whoseslugisSITE_SLUG(defaultdocs). Lookups — found or missing — are cached for 30 seconds; a failed lookup is remembered for 5 seconds and re-thrown, never answered from a stale row. - Version:
{ kind: "commit", sha: DOCS_CONTENT_SHA }when the build baked the commit it ingested, so each deployment serves exactly its own content;"current"otherwise (local development). - Preview:
truewhenVERCEL_ENVispreview. - Canonical host: the host of
SITE_ORIGINin production, unlessDOCS_CANONICAL_REDIRECT=0; never on previews or in development. - Request policy: no HSTS preload, cache tag
site-<slug>, no short-circuit. - Telemetry, WebMCP rollout, rate-limit scope: none; the defaults apply.
| Variable | Default | Effect |
|---|---|---|
SITE_SLUG | docs | Which docs_site row to serve. Lowercase letters, digits and inner hyphens, at most 55 characters. |
DOCS_CONTENT_SHA | unset | The commit the build ingested; set by the build. |
DOCS_CANONICAL_REDIRECT | 1 | 0 disables the production redirect to the SITE_ORIGIN host. |