Boltdocs 3.2.0 — Warm Builds 5x Faster, WASM Syntax Highlighting, and Pipeline Timing

Jesús AlcaláJesús Alcalá
Boltdocs 3.2.0 — Warm Builds 5x Faster, WASM Syntax Highlighting, and Pipeline Timing

3.2.0 makes warm builds 5x faster by fixing the server build skip, adds WASM-powered syntax highlighting, parallelizes critical CSS processing, and shows you exactly where build time goes.

This one's about making cache actually workLink

3.1.0 shipped the foundation — turbo mode, Sätteri, zig-critters. 3.2.0 is about what happens when you build again. And again. And again.

Info
Note

Warm builds (no code changes) dropped from ~50s to ~10s — a 5x speedup. The server Vite build is now properly skipped when nothing changes, the client hash uses a lightweight pre-check, and critical CSS processing runs in parallel across CPU cores.


The cache problemLink

Before 3.2.0, every build ran the full server Vite build from scratch — even when nothing changed. The root cause was a single line at the end of every build:

await fs.remove(join(root, '.vite-react-ssg-temp'))

This deleted the entire SSR build output directory. The next build's serverBuildSkipped check required that directory to exist — but it was always gone. The result: the most expensive step in the pipeline (SSR Vite bundling) ran on every build, making the cache effectively useless.

Info
Note

The fix: Don't delete the SSR temp directory when the client build was bypassed. If the client code hash hasn't changed, the SSR bundle hasn't changed either — no need to rebuild it.

Benchmark resultsLink

Build typeBefore 3.2.0After 3.2.0Speedup
Cold (first build)~82s~78s~1.05x
Warm (no changes)~50s~10s5x faster
Incremental (1 file edit)~50s~15s3.3x faster

The cold build barely changes because the server Vite build must run. But warm and incremental builds now skip it entirely — the server bundle is reused from the previous build.


Mtime cache: 5.9x faster stat callsLink

Every FileCache.get() called fs.statSync() to check if a file had changed — even when the same file was checked milliseconds apart. For 500 files across multiple cache layers, this meant thousands of blocking syscalls per build.

The new in-memory mtime cache stores { mtime, ts } per file with a 2-second TTL. Within the TTL window, the stat call is skipped entirely:

const MTIME_TTL_MS = 2000
const mtimeCache = new Map<string, { mtime: number; ts: number }>()

export function getFileMtime(filePath: string): number {
  const now = Date.now()
  const cached = mtimeCache.get(filePath)
  if (cached && now - cached.ts < MTIME_TTL_MS) return cached.mtime

  const mtime = fs.statSync(filePath).mtimeMs
  mtimeCache.set(filePath, { mtime, ts: now })
  return mtime
}
MetricBeforeAfterSpeedup
5 files × 500 rounds18.6ms3.2ms5.9x

Client hash: single stat per fileLink

computeClientCodeHash() was calling fs.statSync() up to 3 times per file — once for the pre-check Math.max(), once for the hash computation, and once more for the meta persistence. The new implementation does a single pass, collecting all stats upfront and reusing them for every purpose:

// Single pass: stat each file once
const fileStats = files.map(file => {
  const stat = fs.statSync(file)
  return { file, mtime: stat.mtimeMs, size: stat.size }
})

// Pre-check uses fileStats — no extra stat calls
const lastMtime = Math.max(...fileStats.map(s => s.mtime))

// Hash uses the same fileStats
for (const { file, mtime, size } of fileStats) {
  hasher.update(relative(root, file)).update(mtime.toString()).update(size.toString())
}

For a project with 500 files, this reduces stat calls from ~1500 to 500 — a 66% reduction.


MDX cache: path+mtime for dev modeLink

The MDX cache key previously included a content hash (crypto.createHash('md5').update(code)), meaning the cache was invalidated on every keystroke. In dev mode, the cache now uses file path + mtime instead:

// Before: invalidated on every content change
const cacheKey = `${cleanId}:${contentHash}:${isProd}:${MDX_PLUGIN_VERSION}`

// After: survives across dev restarts when files haven't changed
const cacheKey = isDev
  ? `${cleanId}:${getFileMtime(cleanId)}:${isProd}:${MDX_PLUGIN_VERSION}`
  : `${cleanId}:${contentHash}:${isProd}:${MDX_PLUGIN_VERSION}`

This means the MDX transform cache is reused when you restart the dev server without changing any files — no more recompiling 176 MDX files from scratch.


Bounded concurrency for route parsingLink

Route parsing used Promise.all(files.map(...)) with unbounded concurrency — launching all file parsers simultaneously. For projects with hundreds of files, this caused memory pressure and I/O contention.

Now capped at 32 concurrent workers:

parsed = await runWithConcurrency(files, 32, async (file) => {
  const cached = docCache.get(file)
  if (cached) return cached
  const result = await parseDocFile(file, docsDir, finalBasePath, config)
  docCache.set(file, result)
  return result
})

The worker pool pattern pulls items from a shared queue, keeping exactly 32 parsers running at any time — enough to saturate I/O without overwhelming memory.


HMR: O(1) module graph lookupLink

When a file changed, the HMR handler searched Vite's fileToModulesMap with a brute-force O(N) scan — iterating every entry and comparing decoded lowercase keys. For large projects with thousands of modules, this added latency to every content edit.

The fix builds a pre-computed lowercase index on first use:

let lowerModuleIndex: Map<string, any> | null = null

function getLowerModuleIndex(): Map<string, any> {
  if (lowerModuleIndex) return lowerModuleIndex
  lowerModuleIndex = new Map()
  for (const [key, value] of server.moduleGraph.fileToModulesMap.entries()) {
    lowerModuleIndex.set(decodeURIComponent(key).toLowerCase(), value)
  }
  return lowerModuleIndex
}

// On file change:
mods = getLowerModuleIndex().get(normalized.toLowerCase()) || null

The index is invalidated when the module graph changes (onFileChange), keeping lookups O(1) while staying consistent.


docCache: no redundant disk readsLink

docCache.load() was called on every generateRoutes() invocation — reading the entire cache from disk (potentially gzipped JSON) even when it was already loaded in memory. Now a loaded flag prevents re-reading:

async load(): Promise<void> {
  if (this.loaded) return  // Skip if already in memory
  // ... read from disk ...
  this.loaded = true
}

invalidateAll(): void {
  this.entries.clear()
  this.loaded = false  // Reset on invalidation
}

Prewarming with route priorityLink

Previously, all routes were prewarmed in arbitrary order. Pages like /docs/ and /docs/getting-started are visited first by most users, but they weren't prioritized.

Now routes are sorted by priority before batching:

const PRIORITY_PATTERNS = [/\/index\./i, /\/getting-started/i, /\/intro/i, /\/readme/i]

const files = routes
  .filter(r => r.filePath)
  .map(r => r.filePath)
  .sort((a, b) => getRoutePriority(a) - getRoutePriority(b))

The delay was also increased from 0ms to 150ms, giving the first page request a head start before prewarming consumes CPU.


Shiki: highlight at build time, zero client costLink

Syntax highlighting never ships Shiki to the browser. The highlighter runs only during MDX transform (dev and production build). Tokens are baked into the page as real HTML elements — the client just paints spans.

Here's the architecture:

  1. At build time, the rehype plugin walks every ```lang fence (except Mermaid), resolves light/dark themes from theme.codeTheme, and runs Shiki's codeToHast.
  2. The highlighted HAST tree is injected into the document tree — children and properties of the <pre> are replaced in place. There is no giant data-highlighted-html string attribute and no client-side re-tokenization.
  3. Dual themes (light + dark) are encoded on each token via CSS variables, so theme switches are pure CSS — no re-highlight, no second copy of the highlighter.
  4. The client CodeBlock component wraps that pre-rendered markup (copy button, expand, feedback). It never imports shiki, Oniguruma, or language grammars.
Info
Note

Shiki stays on the server. Grammars, themes, and the Oniguruma WASM engine load only in Node during transform. The browser receives static HTML with token colors already applied — same idea as Mermaid pre-render, for every code block.

Before vs. after (client payload)Link

ConcernClient-side highlighterBoltdocs (build-time Shiki)
Shiki + WASM + grammars in the bundleHundreds of KB–MB0 KB — never imported on the client
First paint of a code blockAfter JS loads and tokenizesInstant — spans ship with the page
Light/dark switchRe-highlight or ship two full treesCSS variables on each token
Runtime createHighlighter / codeToHtmlPer page or lazy chunk0 — only runs in the MDX pipeline

Trade-off: the HTML grows (one styled span per token), but the JS bundle does not include Shiki. That is the intentional swap for docs sites where code blocks are static content.

WASM Oniguruma engineLink

Build-time highlighting also got faster: the engine is now WASM Oniguruma instead of the JavaScript regex engine:

// Before
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
engine: createJavaScriptRegexEngine()

// After
import { createOnigurumaEngine } from '@shikijs/engine-oniguruma'
engine: createOnigurumaEngine(import('shiki/wasm'))
MetricJS RegexWASM OnigurumaDifference
3 langs × 50 rounds1101ms959ms13% faster
Per iteration22.0ms17.7ms4.3ms saved

Critical CSS concurrencyLink

The Beasties critical CSS processor was running at concurrency: 1 — processing 174 pages one at a time. Now it runs at concurrency: min(cpus, 4), processing up to 4 pages in parallel:

// Before
const crittersQueue = new PQueue({ concurrency: 1 })

// After
const crittersQueue = new PQueue({ concurrency: Math.min(os.cpus().length, 4) })

Pipeline parallelismLink

Two independent pipeline steps — SEO validation and type generation — now run in parallel:

// Before
.addStep(new SEOValidateStep())
.addStep(new TypeGenerateStep())

// After
.addParallelSteps([new SEOValidateStep(), new TypeGenerateStep()])

Pipeline timing logsLink

The build now reports per-step timing:

[pipeline] Build steps:
  ConfigResolve        52ms
  RouteGenerate        11ms
  SEOValidate          7ms
  TypeGenerate         7ms
  SSGBuild             5.6s
  SEOWrite             4ms
  Total                6.7s

Dev mode: no gzipLink

TransformCache was gzipping every cache shard on write — even in dev mode. Now compression is skipped when NODE_ENV !== 'production'.


A Mermaid bundle that doesn't ship to the browserLink

If a page has diagrams, you expect to wait. Mermaid.js is ~800 KB of d3, DOMPurify, layout engines, and per-diagram parsers — all of it code-split on demand. On pages without a diagram, 2.8.0 already saved ~27 KB with a dynamic import. That helped, but it still left most of the cost on pages that actually used Mermaid.

3.2.0 eliminates it entirely for production builds.

Here's the architecture:

  1. At build time, the Remark plugin walks every ```mermaid block and dispatches it to a persistent Node.js worker (render-worker.mjs) that loads the headless Mermaid engine (via Playwright + jsdom + DOMPurify) and renders both the light and dark theme variants to SVG.
  2. Those two SVGs are attached to the <Mermaid /> component as svgLight and svgDark props — they ride along in the SSG output as plain string props, no extra fetch at runtime.
  3. The plugin then registers a Vite alias that swaps @bdocs/plugin-mermaid/client to @bdocs/plugin-mermaid/client/static whenever vite build runs. The static component (mermaid-static.tsx, served via the render-worker pipeline) never references mermaid — it just dangerouslySetInnerHTMLs the pre-rendered SVG.
  4. In dev, the alias is skipped so HMR keeps the live client renderer. Or opt out explicitly with mermaidPlugin({ preRender: false }).
Info
Note

~800 KB of code-split Mermaid chunksarchitectureDiagram, sequenceDiagram, d3, DOMPurify, layout engines — leave the client bundle entirely. Pages render the diagram as inline SVG: zero JS round-trip, no FOUC, no flash of fallback.

Before vs. afterLink

Page typeBefore 3.2.0After 3.2.0
Page without diagrams~0 KB mermaid runtime (dynamic import in useEffect)~0 KB — the static component never references mermaid
Page with diagrams~800 KB of mermaid chunks loaded on demand, then mermaid.initialize + render~0 KB — SVG is already in the initial HTML
First diagram paintAfter client bundle + engine initialize (~depends on connection)Instant — SVG ships with the page
Theme switchRe-runs mermaid.initialize and re-renders on every changeInstant — swaps svgLightsvgDark
Client-side mermaid.initialize() calls per page1 (one per mount)0 — light/dark SVGs are serialized into HTML

Trade-offsLink

This is opt-out, not opt-in via the preRender: false flag. Three things to know:

  • Build time: every ```mermaid block renders through the Playwright-driven worker once. For most projects it adds a few seconds; for projects with thousands of diagrams it can cost more (run your own benchmark). The worker is persistent and processes diagrams sequentially so the Playwright context stays warm.
  • Pre-render failures: if Mermaid throws on a diagram (e.g. syntax error), the static component falls back to displaying the raw chart source. You'll see a small warn at build time — no client-side crash, no missing diagram.
  • Truly dynamic diagrams: this path is for static SVGs. If you need runtime diagrams (e.g. user-provided input), set mermaidPlugin({ preRender: false }) and the legacy client renderer stays in place. The alias backs off automatically.

Mermaid fullscreen animationsLink

While we were in there, the fullscreen overlay picked up entrance/exit animations — a small detail that makes the feel match the rest of the site.


Other improvementsLink

  • Pipeline addParallelSteps() — new API for running independent pipeline steps concurrently, with automatic rollback on failure
  • Server build skip preserved across builds — SSR output now lives under .boltdocs/build/ssr/ and is no longer deleted when client code hasn't changed
  • Hash meta persistencehash-meta.json for fast cache validation without full directory scans
  • External page scroll fix — removed overflow: hidden from html, body
  • Invalid Tailwind classes fixedfrom-bg-mainfrom-main, -z-1-z-[1]

Package weight reductionLink

3.2.0 passes the heaviest tools in the framework through the same triage as the build pipeline: bundle what runs every page, defer what runs only sometimes, hoist to peers what the consumer already needs anyway. The result is a noticeably smaller install, a code-split client bundle, and a leaner node_modules for sites that don't use every feature.

Why this mattersLink

The 3.1.x install carried react-aria-components as a hard dependency alongside the entire Mermaid pre-render toolchain, language icons, and the image optimizer native binaries — even for users who never rendered a Mermaid diagram, never had a code block, and never felt a need for sharp/svgo. 3.2.0 separates the what from the when.

Before vs. afterLink

Two categories of "size" — client bundle (bytes shipped to every browser) and node_modules footprint (bytes unpacked on disk per consumer install) — measured separately so you can read the table top-to-bottom without confusion.

Client bundle — bytes shipped to browsers (per cold-load)

SurfaceBefore 3.2.0After 3.2.0Notes
client/index.js (front door)108 KB108 KBUnchanged at the entry; savings are in deferred chunks.
icons-dev.tsx language icon blob in the initial chunk44 KB on every page0 KB on all pagesThe entire lang-icons.tsx module (twelve SVG icons + registry map) was removed from the core. Code block titles render the filename text only — no language icon is shipped to the client. Pages with code blocks save ~17 KB versus 3.1.x's eager bundle; pages without code blocks were already at zero bytes for these icons.
Social/nav icons in the navbar bundlebundled with everything~1 KB isolated in icons-prod.tsxGithub, Discord, XSocial, Bluesky are namespaced separately so layouts that don't need them can override without dragging the language set along.

node_modules footprint — bytes unpacked on disk (per pnpm install)

SurfaceBefore 3.2.0After 3.2.0Notes
react-aria-components (unpacked)~1 MB transitively in node_modules0 KB in coreNow a required peer — consumers install it once alongside React. No double-bundling.
sharp + svgo native binaries~35 MB unpacked for every site install0 KB in core; install only if you use @bdocs/plugin-image-optimizerMoved out of core into the image plugin's peerDependenciesMeta.optional. Alpine ARM, musl libc, and old glibc users no longer hit hard sharp postinstall failures during boltdocs install.
shiki + @shikijs/engine-oniguruma + @mdx-js/rollupSame as 3.1.xSame as 3.1.xStayed in dependencies — the CLI unconditionally imports them when npx boltdocs build runs. They never reach the client bundle.
optionalDependencies sectionpresent (empty)removed entirelyThe "silent-install trap" of optionalDependencies is gone.
Question: "does my site install 35 MB of native binaries to render docs?"YesNo — only if you opt into the image optimizerMost sites just say "no thanks" and skip the native cost.

What changed (semver-wise: minor)Link

This release sits comfortably in the documented semver-minor bin because the public API contracts remain strict. Here's the audit:

  1. react-aria-components — promoted from dependencies to a required peer. If you already use Boltdocs, install it once alongside React. If you don't, the next pnpm install will surface a peer-warning that you can ignore if your framework wrapper provides React Aria, or satisfy with pnpm add react-aria-components. The peer is not marked optional, so a missing peer is a hard install-time warning — but the runtime was already importing it before, so the binary behavior is unchanged.

  2. sharp and svgo — they moved out of core entirely. They used to be transitively installed through boltdocs; now they're a peer of @bdocs/plugin-image-optimizer with peerDependenciesMeta.optional: true. If your site doesn't use the image optimizer, you save ~35 MB of unpacked native binaries. If you do use it, you get the same binaries — pnpm hoists them via the plugin's own peer declarations.

  3. Lang icons in MDX code blocks — twelve language icons (TypeScript, JavaScript, React, JSON, CSS, HTML, Markdown, Shell, YAML, Rust, TOML, CSV) were eagerly bundled in 3.1.x, then lazy-loaded in lang-icons.tsx. The whole module has now been removed from the core. Code block titles render the filename text with a generic File icon — no per-language icon shipped to the client at all. Pages with code blocks save ~17 KB versus 3.1.x; pages without code blocks were already at zero bytes.

  4. Social/nav iconsGithub, Discord, XSocial, Bluesky moved into a new icons-prod.tsx file that's eagerly bundled with the navbar. They're separately typed (IconProps) and exported from their own entry so end-user custom layouts can override them without touching the language icon set.

  5. Shape test — a new packages/core/tests/package-shape.test.ts pins the dependency contract: it asserts react-aria-components is a hard peer, shiki/@shikijs/engine-oniguruma/@mdx-js/rollup remain in dependencies, sharp/svgo are not in core, and peerDependenciesMeta is absent entirely. This means any future PR that re-bloats the surface area fails CI before review.

  6. Client subpath exports preserved'boltdocs/client', 'boltdocs/primitives', 'boltdocs/mdx', 'boltdocs/server' remain split so apps can selectively import what they need without over-bundling.

Migration in one pasteLink

// package.json
{
  "dependencies": {
    // ...existing...
    "boltdocs": "^3.2.0",
    // ADD this one line (or accept the peer warning):
    "react-aria-components": "^1.16.0"  // was transitive; now needed explicitly
  }
}

No code changes. No config changes. Plugin/image-optimizer peer handling is unchanged if you already had it installed.

CI / lockfile-strict setupsLink

Teams that hard-fail builds on peer warnings (Husky pre-commit, Renovate policies, monorepos with pnpm install --frozen-lockfile --strict-peer-dependencies) need a single line in .npmrc to silence only this peer warning without hiding real breakage:

Info
Note

Pick one, not both. Use either .npmrc OR .pnpmrc, not both. Picking both can double-hoist or hit a pnpm parsing quirk (public-hoist-pattern[] is npm-style array syntax; pnpm reads .pnpmrc as bracket-less per-line). Most teams only need one of the two.

# .npmrc
# Allow the documented boltdocs@3.2 → react-aria-components peer advisory only.
# Do NOT blanket-disable peer checks with legacy-peer-deps.
public-hoist-pattern[]=*react-aria-components*

Or, equivalently in .pnpmrc (pnpm-native syntax — no brackets):

# .pnpmrc
# Make react-aria-components an explicit, transparent peer in your lockfile.
# `.pnpmrc` does NOT use the `[]` array syntax — pnpm-native is comma-less per-line.
public-hoist-pattern=*react-aria-components*
peerDependencyRules.allowedVersions.react-aria-components=^1.16.0

Don't use legacy-peer-deps=true. That silences every peer warning across the tree and will mask future real breakages.

Read the full audit in Upgrading to 3.2.0 → Semi-breaking changes.


New @bdocs/unist-utils PackageLink

Boltdocs 3.2.0 extracts the shared AST utilities into a standalone npm package: @bdocs/unist-utils. Before this release the same helpers (visitNodes, createMdxElement, setNodeProperty, parseMetaString, etc.) lived in two places inside the monorepo with subtly different typings; now they have a single source of truth.

pnpm add @bdocs/unist-utils

Why this matters for plugin authors:

  • 100% typed surface. No any, no unknown at the boundaries. Node, Parent, ElementNode, MdxJsxElement are all declared in the package so plugin authors can write fully typed code.
  • Decoupled from boltdocs internals. External plugins no longer need to reach into Boltdocs' internal barrel or write their own AST helpers with looser types.
  • Re-exported by boltdocs for back-compat. Old code importing from 'boltdocs' keeps working. New projects should prefer import ... from '@bdocs/unist-utils'.

The package includes: visitors (visitNodes, visitRehypeElements, visitMdxElements), builders (createMdxAttribute, createMdxElement, createRehypeElement), h-properties helpers, class-list mutation (addNodeClass, removeNodeClass, hasNodeClass), meta string parsing (parseMetaString), and type guards.

Info
Note

See the full @bdocs/unist-utils docs for API reference, migration guide, and examples.


Enriched Plugin APILink

Every plugin lifecycle hook now receives a richer PluginContext with seven enriched namespaces. The slot subsystem (ctx.slots / BoltdocsPlugin.slots / virtual:boltdocs-layout-slots / slots-prefixed diagnostics) has been removed entirely. The remaining namespaces — caches, diagnostics, paths, virtual modules, middleware, server, hmr — work exactly as before:

ctx.caches — PluginCachesAPILink

Three cache types, each bound to a namespaced scope so plugins never collide:

// Transform cache (sharded, hash-keyed, async)
const cache = ctx.caches.transform('my-plugin')
await cache.get('key')  // → string | null
cache.set('key', value)

// Routes cache (read/write/invalidate by file path)
const route = ctx.caches.routes.get('/abs/path/page.mdx')
ctx.caches.routes.invalidate('/abs/path/page.mdx')

// In-memory FIFO cache (Map-backed, no external deps)
const mem = ctx.caches.memory<MyType>('my-plugin', { max: 50, ttl: 60_000 })
mem.set('key', value)
mem.get('key')

ctx.diagnostics — PluginDiagnosticsAPILink

Structured diagnostics channel instead of logger spam. Records accumulate in a FIFO-capped queue (256 entries) that downstream tools can drain:

ctx.diagnostics.report(
  'warn',
  'MY_PLUGIN_SLOW',
  'Transformation took 3.2s',
  { filePath: '/abs/path/page.mdx' },
)
const allRecords = ctx.diagnostics.list()
ctx.diagnostics.clear()

ctx.paths — PluginPathsAPILink

Safe path resolution with anti-traversal validation. Every result is guaranteed to stay inside the workspace boundary, rejecting .. escapes and absolute path injection:

const mdxFile = ctx.paths.resolveDocs('guides', 'start.mdx')
const asset = ctx.paths.resolveAsset('public', 'logo.webp')
const url = ctx.paths.safeFileURL('/abs/path/diagram.svg')

// Throws — path escapes the workspace
ctx.paths.resolveDocs('../../etc/passwd')

ctx.virtualModules — PluginVirtualModulesAPILink

Declare virtual modules that Vite resolves and loads without touching the file system. Registrations happen inside beforeBuild / beforeDev and are flushed on config change:

ctx.virtualModules.add(
  'virtual:my-plugin/theme.css',
  () => `:root { --primary: #6366f1; }`,
)

Registration rules: duplicate ids throw, virtual:boltdocs-* prefix is reserved, and the eager flag exists for future auto-injection.

Info
Note

See the Plugin API Reference for the complete reference with all method signatures and a worked example. The API is now split into dedicated pages — Caches, Diagnostics, Paths, Virtual Modules, Middleware, Server, and HMR.


Transform Middleware APILink

Plugins can now register standalone transform middleware — smaller, focused functions that transform source, MDX, or HTML content in a pipeline.

Unlike lifecycle hooks, middleware can be declared statically via BoltdocsPlugin.middleware or registered programmatically from any hook via ctx.middleware.add(). Each middleware supports enforce ordering and signal-based chain control (__signal: 'skip' / __signal: 'break'):

const plugin = {
  name: 'my-plugin',
  middleware: [{
    name: 'inject-copyright',
    transformHtml: async (_ctx, { html, path }) => ({
      html: html.replace('</body>', '<footer>© 2026</footer></body>'),
    }),
  }],
}

See the Middleware API docs for the full reference.


New <Timeline> MDX componentLink

3.2.0 ships a vertical-timeline MDX component built for changelogs, release notes, status updates, and audit logs. Each entry has a colored dot strung onto the connector line, an optional date and badge, a title, and a Markdown body.

<Timeline>
  <Timeline.Item
    date="2026-07-20"
    title="Boltdocs 3.2.0"
    badge="Major"
    icon={<Sparkles />}
  >
    Released **Plugin v3.2 API** — caches, diagnostics, paths, virtual modules,
    middleware, server, and HMR hooks. See the [migration guide](/docs/blog/boltdocs-3.2.0).
  </Timeline.Item>

  <Timeline.Item
    date="2026-06-01"
    title="Added i18n support"
    badge={{ text: 'Minor', variant: 'success' }}
  >
    New locale filesystem convention, version-aware routes, and SSR-safe URL prefixes.
  </Timeline.Item>
</Timeline>

Renders as:

●  Jul 20, 2026  [MAJOR]
│  Boltdocs 3.2.0
│  Released Plugin v3.2 API — caches, diagnostics, paths, virtual modules,
│  middleware, server, and HMR hooks. See the migration guide.

●  Jun 1, 2026   [MINOR]
│  Added i18n support
│  Locale filesystem convention, version-aware routes, and SSR-safe URL prefixes.

What's in the boxLink

  • Auto-registered — write <Timeline> in any .mdx file. No import needed.
  • Two variant families — semantic (primary, success, info, warning, danger) plus lifecycle aliases (major, minor, patch, new, deprecated, breaking) so changelogs can colour by release type without retyping hex codes.
  • Hydration-safe localized dates — the date prop renders via toLocaleDateString pinned to a stable locale ('en-US' by default) so server output and client output always agree.
  • Body accepts full Markdown — paragraphs, code, links, inline <Callout>, lists. Whatever fits in an MDX paragraph fits inside an item.
  • Compact mode<Timeline compact> for dense lists of small tweaks.
  • Data-driven generation — every Timeline.Item is a normal React element, so you can .map() over a JSON array of releases to render an entire changelog without retyping each release block by hand.
Info
Note

See the full Timeline docs for the complete component API, accessibility notes, and the data-driven changelog pattern.


UpgradingLink

For most teams, this is a one-line install:

pnpm add boltdocs@latest

A small subset of installs will see a peerDependencies warning from npm/pnpm:

boltdocs@3.2.0 requires react-aria-components@^1.16.0 as a peer

Satisfy it with pnpm add react-aria-components (or pin a lower version if your React Aria vendor already provides it). Sites that use @bdocs/plugin-image-optimizer are unaffected — the optimizer's own peer declarations still work, and sharp/svgo install via the optimizer's peerDependenciesMeta.optional: true.

See Upgrading to 3.2.0 → Semi-breaking changes for the full audit, before/after package.json snippets, and platform-specific notes.

Check the full docs to explore everything new.

Last updated on July 27, 2026