| name | new-version | ||
|---|---|---|---|
| type | registry:feature | ||
| title | Add a docs version | ||
| description | End-to-end setup for adding a docs version to a Nimbus site. Creates the content directory, registers the collection, scaffolds routes, declares the manifest, installs the version-switcher picker, and wires it into your Header and DocsLayout. One command for one mental task. | ||
| markers |
|
You are helping the user add a version of the docs to a Nimbus
site. A version is a frozen snapshot of the docs at a point in time:
the user is shipping v2 of their product and wants to keep v1's docs
navigable at /v1/, or they're cutting docs for a new API release
while preserving the old API's docs.
This recipe owns the whole setup, end to end. Adding a version touches four concerns at once — content, collection registration, the versioning manifest, the picker — and this recipe handles all of them in a single conversation. The user does not pre-create files, edit configs, or copy components manually.
Read this entire file before making any changes.
Before prompting the user or writing anything, inspect the project:
package.json— confirmnimbus-docsis a dependency.src/content.config.ts— read in full. Capture all of:-
The existing collections (you must not collide with names).
-
The import style (named imports vs default,
defineCollectionlocation). -
Whether
docsCollectionfromnimbus-docs/contentis already imported. -
Whether the existing
docscollection extends the schema viaschemaFields, e.g.docs: defineCollection( docsCollection({ schemaFields: { audience: z.literal("human").optional() }, }), ),
If so, the new versioned collection MUST mirror those
schemaFields— otherwise frontmatter validation diverges between current and the frozen version, and previously-valid pages will fail to build in the version collection. Capture the exact shape and reuse it in step 4b.
-
astro.config.ts(or whereverdefineNimbusConfig(...)lives) — read the Nimbus config. Note whether aversions:block already exists. If it does, the user is adding another version to an existing setup; if not, this is the first version.src/pages/[...slug].astro— the primary docs route. The new version's route will mirror its shape, including any custom prop forwarding. Specifically, scan its<DocsLayout>call for any<field>={entry.data.<field>}lines that aren't in the recipe's standard set (title,description,sidebar,headings,breadcrumbs,prevNext,mode,banner,head,searchable,noindex,markdownUrl,socialImage,lastUpdated,editUrl,draft,collection,entryId). Any extras almost certainly correspond to theschemaFieldsyou'll capture below; they must be forwarded on the new version's route too (step 4d explains how).src/pages/[...slug]/index.md.ts— the primary.mdalternate route. Same.src/layouts/DocsLayout.astro— must acceptcollectionandentryIdprops and forward them. If those props are missing, the user is on an older starter; tell them to upgrade before continuing (the picker can't function without them).src/components/Header.astro— note whetherVersionSwitcheris already imported. If yes, the picker is installed and this recipe only needs to register the new version; the existing picker will pick it up automatically. If no, this recipe will install and wire the picker.src/components/ui/version-switcher/— check if the directory exists. If yes, the component is already installed. If no, this recipe will copy it from the framework's registry.
If any of the primary docs routes or DocsLayout.astro are missing,
the user is on a stripped-down starter — stop and tell them to run
nimbus-docs add ai-native and ensure their layouts are up-to-date
first.
Ask three to five questions in this order. Use prior answers to default later ones.
The slug is the URL prefix. Common shapes:
- Major versions:
v0,v1,v2,v3— best for SDKs / frameworks - Calendar versions:
2024-q4,2025-q1— best for APIs - Named versions:
legacy,archive— best when "v1 vs v2" overstates the change
Rules:
- Lowercase,
a-z/0-9/-/_only — no dots, no spaces. The slug feeds into URLs. - Must not equal the existing
versions.currentvalue (if one is set).
Tell the user the resulting URL prefix will be /<slug>/<page>.
Q2. Is this the current version's label, or an older snapshot you're freezing? (default: older snapshot)
Two flows:
(a) Freezing the current docs as <slug> (most common). The user is
about to start work on a new major version. They want to snapshot
today's src/content/docs/ as <slug> BEFORE editing. In this case,
you will:
- Copy
src/content/docs/*→src/content/docs-<slug>/* - Set the manifest's
currentto whatever LABEL the user wants for the new version they're about to start (e.g. they froze v1, they're starting v2, socurrent: "v2").
(b) Adding an older snapshot they have separately. The user has content for an older version they want to add. You will:
- Create an empty
src/content/docs-<slug>/directory + a starter MDX - The user pastes their content in afterwards
- Leave
currentas whatever it already is (or ask the user).
Confirm which flow before proceeding.
The user just froze v1 as <slug>=v1. What's the new current
version's label? v2 is the obvious default; ask before assuming.
Default: no (just freezing it normally).
If yes, the recipe adds <slug> to versions.deprecated. That
triggers:
- Yellow caution banner on every page in the version
- Pagefind search excludes the version from default results
- Picker shows a
deprecatedbadge inline
Useful when the user is freezing an EOL version they want to keep navigable but discourage.
Q5. Should this version be hidden?
Default: no.
If yes, the recipe adds <slug> to versions.hidden. That means the
version's URLs resolve but it's excluded from:
- The picker dropdown
- Pagefind search index entirely
/llms.txt(root + per-version)- Cross-version
<link rel="alternate">tags
Useful for in-progress drafts, marketing-published-but-incomplete versions, or versions kept for one customer.
Print an exact plan before writing anything. Example:
I'll do the following:
- Copy
src/content/docs/*→src/content/docs-v1/*(your current docs become the frozen v1).- Add
"docs-v1": defineCollection(docsCollection({ base: "docs-v1" }))tosrc/content.config.ts.- Add to
astro.config.ts:versions: { current: "v2", others: ["v1"], }- Write
src/pages/v1/[...slug].astro(page route).- Write
src/pages/v1/[...slug]/index.md.ts(markdown alternate).- Copy
VersionSwitcher.astro,index.ts, andREADME.mdintosrc/components/ui/version-switcher/.- Edit
src/components/Header.astroto render<VersionSwitcher collection={collection} entryId={entryId} />.- Edit
src/layouts/DocsLayout.astroto render the mobile picker inside the sidebar drawer.Final result: visit
/(v2 current) and/v1/<page>(frozen v1). The picker switches between them. Proceed?
Wait for explicit confirmation.
For flow (a) — freezing current:
cp -R src/content/docs/. src/content/docs-<slug>/If src/content/docs/ doesn't exist or is empty, tell the user there's
nothing to freeze and stop.
For flow (b) — empty draft:
mkdir -p src/content/docs-<slug>Then write src/content/docs-<slug>/welcome.mdx. Replace every
occurrence of <slug> below with the actual version slug the user
chose in Q1 (e.g. v0, v1, 2025-q1). The file as written
contains four <slug> tokens — substitute all of them. Do not ship
literal <slug> angle brackets to the user's content:
---
title: Welcome
description: Placeholder entry for the <slug> version. Replace with real content.
---
This is the `<slug>` version of the docs. Replace this file with your
real content.Add to the collections object. If the existing docs collection
uses plain docsCollection with no schema extension, add:
"docs-<slug>": defineCollection(docsCollection({ base: "docs-<slug>" })),If the existing docs collection has custom schemaFields (you
captured this in Discovery), mirror the entire schemaFields block
onto the new versioned collection:
"docs-<slug>": defineCollection(
docsCollection({
base: "docs-<slug>",
schemaFields: { /* same shape as the docs collection's schemaFields */ },
}),
),Mirroring is required: a versioned collection without the same schema extensions will fail to build any frozen page that uses those custom frontmatter fields.
If docsCollection / defineCollection aren't imported in the file
yet, add the imports:
import { defineCollection } from "astro:content";
import { docsCollection } from "@cloudflare/nimbus-docs/content";Find the defineNimbusConfig(...) block. Two cases.
Case A — no versions block yet. Add one. Pick the matching
shape based on the user's answers to Q4 (deprecated) and Q5 (hidden):
Neither deprecated nor hidden:
versions: {
current: "<current-label>",
others: ["<slug>"],
},Deprecated only:
versions: {
current: "<current-label>",
others: ["<slug>"],
deprecated: ["<slug>"],
},Hidden only:
versions: {
current: "<current-label>",
others: ["<slug>"],
hidden: ["<slug>"],
},Both deprecated and hidden (rare):
versions: {
current: "<current-label>",
others: ["<slug>"],
deprecated: ["<slug>"],
hidden: ["<slug>"],
},In all four shapes, <current-label> comes from Q3 in flow (a) (the
label for the version the user is now starting work on), or from
the existing current value in flow (b).
Case B — existing versions block. Append <slug> to others.
If the user said yes to deprecation, also append to deprecated
(create the field if it doesn't exist yet). Same for hidden. Do
not touch current unless the user explicitly asked to bump it.
Example: if the existing block is
versions: { current: "v3", others: ["v2", "v1"], deprecated: ["v1"] }…and the user is adding v0 deprecated, edit to:
versions: { current: "v3", others: ["v2", "v1", "v0"], deprecated: ["v1", "v0"] }Helper choice — this is the #1 thing fresh agents get wrong here.
The user's primary src/pages/[...slug].astro route uses
getDocsStaticPaths and getDocsPageProps. Do not copy those.
They are hardcoded to the primary docs collection. For any
non-primary collection (including every version), use the generic
siblings:
getCollectionStaticPaths("docs-<slug>")— takes the collection name as an argumentgetCollectionPage<"docs-<slug>">(Astro)— takes the collection name as a TypeScript generic
The snippet below uses the correct helpers. Copy it verbatim and
substitute <slug> — do not be tempted to "match" the primary
route's getDocs* calls.
Write src/pages/<slug>/[...slug].astro (literal <slug> directory
name with the user's slug):
---
import DocsLayout from "../../layouts/DocsLayout.astro";
import {
getCollectionStaticPaths,
getCollectionPage,
getSidebar,
getPrevNext,
getBreadcrumbs,
getEditUrl,
getLastUpdated,
getRouteFlags,
getTOC,
entryRouteKey,
stripBase,
} from "@cloudflare/nimbus-docs";
import { components } from "../../components";
export const prerender = true;
export const getStaticPaths = getCollectionStaticPaths("docs-<slug>");
const page = await getCollectionPage<"docs-<slug>">(Astro);
if (page instanceof Response) return page;
const { entry, Content, headings } = page;
const currentSlug = stripBase(Astro.url.pathname, import.meta.env.BASE_URL).replace(/\/$/, "") || "/";
const { tableOfContents: tocOn } = await getRouteFlags(entry);
const sidebar = await getSidebar(currentSlug, { collection: entry.collection });
const prevNext = await getPrevNext(currentSlug, {
sidebarTree: sidebar,
overrides: { prev: entry.data.prev, next: entry.data.next },
});
const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collection });
const editUrl = await getEditUrl(entry);
const lastUpdated = entry.data.lastUpdated ??
await getLastUpdated(entry);
const tocConfig = entry.data.tableOfContents;
const toc = tocOn && tocConfig !== false ? getTOC(headings, tocConfig) : false;
const routeKey = entryRouteKey(entry.id);
const markdownPath = routeKey
? `/<slug>/${routeKey}/index.md`
: "/<slug>/index.md";
const markdownUrl = markdownPath;
const socialImage = entry.data.socialImage ?? `/og/<slug>/${entry.id}.png`;
---
<DocsLayout
title={entry.data.title}
description={entry.data.description}
sidebar={sidebar}
headings={toc}
breadcrumbs={breadcrumbs}
prevNext={prevNext}
mode={entry.data.mode}
banner={entry.data.banner}
head={entry.data.head}
searchable={entry.data.searchable}
noindex={entry.data.noindex}
markdownUrl={markdownUrl}
socialImage={socialImage}
lastUpdated={lastUpdated}
editUrl={editUrl}
draft={entry.data.draft}
collection={entry.collection}
entryId={entry.id}
>
<Content components={components} />
</DocsLayout>Substitute the user's version slug for every <slug> token in
the snippet above (the directory name in the file path, plus the
several references inside the file body). Don't ship literal angle
brackets to the user's repo.
The collection={entry.collection} and entryId={entry.id} props on
<DocsLayout> are required — they're what drives the picker, the
deprecation banner, the cross-version alternates, and the canonical
link. Do not omit them.
If you captured custom schemaFields in Discovery (step 1), you
also need to forward the matching props to <DocsLayout> —
otherwise the schema validates correctly but the values silently
never reach the UI. Open the primary route at
src/pages/[...slug].astro and look at its <DocsLayout> call. Any
custom-prop forwarding lines like audience={entry.data.audience},
<myField>={entry.data.<myField>}, etc. that exist there must be
added to your new version route's <DocsLayout> call too. Example:
if the primary route has
<DocsLayout
...
audience={entry.data.audience}
>…add audience={entry.data.audience} to your <DocsLayout> here so
v0 pages with audience: "human" render the same "For humans"
treatment as v1 pages.
Write src/pages/<slug>/[...slug]/index.md.ts:
import {
getPreparedMarkdownArtifact,
getPreparedMarkdownStaticPaths,
type PreparedMarkdownReference,
} from "@cloudflare/nimbus-docs/build";
export const prerender = true;
const COLLECTION = "docs-<slug>";
interface SlugProps {
artifact: PreparedMarkdownReference;
}
export const getStaticPaths = () =>
getPreparedMarkdownStaticPaths({ collection: COLLECTION, surface: "markdown" });
export async function GET({ props }: { props: SlugProps }) {
const artifact = await getPreparedMarkdownArtifact(props.artifact);
return new Response(artifact.body, {
headers: { "Content-Type": artifact.mediaType },
});
}Substitute the user's version slug for every <slug> token in the
snippet above (the directory name in the file path, plus the
COLLECTION constant value at the top).
The prepared artifact includes the version frontmatter so agents can pin a
version. To also serve the expanded source form, mirror this route with
surface: "source" at src/pages/<slug>/[...slug]/index.mdx.ts.
If src/components/ui/version-switcher/ already exists, skip this
step — the component is already installed.
Otherwise, install it via the registry CLI:
pnpm exec nimbus-docs add version-switcher --yesThat command copies three files into the user's project:
src/components/ui/version-switcher/
├── VersionSwitcher.astro
├── index.ts
└── README.md
If the CLI fails (404 from the registry, network error, etc.), recover in this priority order:
-
You're testing this recipe pre-release inside the Nimbus monorepo. The registry isn't deployed yet for the
version-switcherslug. Recovery:# Terminal 1 (from monorepo root) pnpm --filter @nimbus/www generate-registry node apps/www/scripts/serve-registry.mjs # → [serve-registry] http://localhost:8901
# Terminal 2 export NIMBUS_REGISTRY_URL=http://localhost:8901 pnpm exec nimbus-docs add version-switcher --yes
-
You're a fresh user and the live registry is down or stale. Fall back to copying the files directly from the framework's GitHub repo (the registry hosts the same content):
curl -fsSL https://raw.githubusercontent.com/cloudflare/nimbus/main/packages/nimbus-starter-source/src/components/ui/version-switcher/VersionSwitcher.astro \ -o src/components/ui/version-switcher/VersionSwitcher.astro curl -fsSL https://raw.githubusercontent.com/cloudflare/nimbus/main/packages/nimbus-starter-source/src/components/ui/version-switcher/index.ts \ -o src/components/ui/version-switcher/index.ts curl -fsSL https://raw.githubusercontent.com/cloudflare/nimbus/main/packages/nimbus-starter-source/src/components/ui/version-switcher/README.md \ -o src/components/ui/version-switcher/README.md
(Create
src/components/ui/version-switcher/first if it doesn't exist.) -
You're an agent running inside the Nimbus monorepo (file system access) and neither of the above is convenient. Read the source directly:
packages/nimbus-starter-source/src/components/ui/version-switcher/Copy
VersionSwitcher.astro,index.ts, andREADME.mdverbatim into the user'ssrc/components/ui/version-switcher/.
Do NOT abandon the recipe at this step. If you can't get the picker files in via ANY of these three paths, that's a bug — report it specifically rather than silently skipping the picker (a "successful" recipe run with no picker is the worst outcome).
Edit src/components/Header.astro:
If VersionSwitcher isn't already imported, add the import alongside
the other component imports:
import { VersionSwitcher } from "./ui/version-switcher";If the file doesn't already accept collection / entryId as props,
add them at the top:
interface Props {
collection?: string;
entryId?: string;
}
const { collection, entryId } = Astro.props;Then render <VersionSwitcher collection={collection} entryId={entryId} />
inside the right-side cluster of the header (typically next to
<SearchTrigger />):
<div class="flex items-center gap-2">
<VersionSwitcher collection={collection} entryId={entryId} />
{config.search !== false && <SearchTrigger />}
{/* ... other header controls ... */}
</div>Edit src/layouts/DocsLayout.astro:
Add the import alongside the others:
import { VersionSwitcher } from "@/components/ui/version-switcher";Find the mobile sidebar drawer — the <dialog data-mobile-sidebar>
block. Inside it, there's a <nav> that renders the sidebar. The
starter ships this with a hasSidebar slot conditional that lets
parent layouts override the sidebar entirely:
<nav class="px-4 pb-8 pt-5">
{hasSidebar ? <slot name="sidebar" /> : (
<Fragment>
<SidebarFilter />
<Sidebar items={sidebar} />
</Fragment>
)}
</nav>Add the picker as the first child of the <nav>, BEFORE the
conditional. That way it shows on every page regardless of
whether the parent provides a custom sidebar slot — matching the
desktop header's unconditional render:
<nav class="px-4 pb-8 pt-5">
<VersionSwitcher collection={collection} entryId={entryId} variant="sidebar" />
{hasSidebar ? <slot name="sidebar" /> : (
<Fragment>
<SidebarFilter />
<Sidebar items={sidebar} />
</Fragment>
)}
</nav>If the layout you're editing has NO hasSidebar conditional (older
starter or custom layout), just add the picker above the existing
sidebar/filter render. The placement principle is the same: picker
at the top of the drawer's nav region.
Confirm the layout already forwards collection and entryId to
<Header />:
{hasHeader ? <slot name="header" /> : <Header collection={collection} entryId={entryId} />}If it doesn't, add the props. The picker can't compute per-page hrefs without them.
After writing all files:
- Run the user's build command (
pnpm build/npm run build/yarn build). - Confirm the build completes without errors.
- Confirm the dist output contains:
dist/<slug>/welcome/index.html(or the equivalent first entry)dist/<slug>/welcome/index.mddist/<slug>/llms.txt(if the version has ≥ 2 entries)
- Tell the user the URLs to visit:
http://localhost:<port>/(current version)http://localhost:<port>/<slug>/<page>(frozen version)- Click the picker in the header to switch between them.
If the user added deprecated: true, also confirm the yellow caution
banner renders on the frozen version's pages with a working link to the
current sibling.
If src/pages/<slug>/[...slug].astro already exists, this version is
already set up. Don't overwrite. Ask the user whether to:
- Skip and exit
- Update the manifest only (add to
versions.deprecatedorversions.hidden) - Replace the existing routes (show a diff first)
- Versioning is one concept; this recipe is one command. Don't
split the user's mental task across multiple recipes. If
new-collectioncould do half the job and version-switcher's manual wiring could do the other half, you'd be doing the user's UX a disservice. Own it end-to-end. - The picker's
latesttag automatically applies to whichever version iscurrentin the manifest. The user doesn't need to configure it. - The cross-version URL convention is fixed: current at root, others at
/<slug>/. The recipe doesn't ask about this — it's the framework contract. - Cross-version
<link rel="alternate">and<link rel="canonical">tags are emitted automatically byNimbusHeadonce thecollectionandentryIdprops flow through the layout chain. Don't try to inject them manually.