Site-level config
Six small writedocs.json fields that each solve one common site-wide need, none of them requiring a whole new page or component to use.
redirects
An array of { source, destination } pairs:
{
"redirects": [
{ "source": "/old-page", "destination": "/docs/getting-started" },
{ "source": "/v1/setup", "destination": "/docs/setup" }
]
}Visiting source on the built site sends the visitor to destination. writedocs build also writes a _redirects file into dist/ alongside the site — a format Cloudflare Pages and Netlify both read natively and turn into a real, instant edge redirect (no page ever renders). Deployed to one of those hosts, that’s what a visitor actually gets. On any other static host (S3, GitHub Pages, a plain nginx server, etc.), the _redirects file is simply ignored, and the visitor instead lands on a client-side redirect page (a <meta http-equiv="refresh"> tag) that sends them on automatically — a brief flash of an unstyled page rather than the instant redirect the edge-hosted case gets. Either way there’s no real HTTP 3xx status code at that URL, since a static build has no server to issue one from — worth knowing if you’re checking redirect behavior with a tool that inspects raw status codes rather than rendering the page.
The same applies to the automatic / → first-navigation-page redirect a site gets for free when no page’s slug is index (see Navigation basics) — it’s covered by the same generated _redirects file.
variables
A flat map of names to text, substituted into every page’s prose wherever you write [[name]]:
{
"variables": {
"productName": "Acme",
"supportEmail": "support@acme.com"
}
}Welcome to [[productName]]! Questions? Reach us at [[supportEmail]].Change the value once in writedocs.json and every page using [[productName]] updates together. A placeholder with no matching entry (a typo, or one you haven’t defined yet) is left as literal text rather than silently disappearing, so a mistake is easy to spot in the built page. Text inside a fenced or inline code block is never substituted — `[[productName]]` inside backticks always renders as literal backtick text.
Use [[name]], not {{name}}. MDX treats a single pair of curly braces as an embedded JS expression, so unescaped double curly braces in your prose parse as a JS object literal rather than literal text — the page would fail to build instead of substituting the value.
banner
A dismissible strip above the top navigation bar:
{
"banner": {
"content": "You're viewing docs for an upcoming release.",
"dismissible": true,
"type": "warning"
}
}type is "info" (default), "warning", or "critical", each with its own color. dismissible (default false) adds a close button; once dismissed, it stays dismissed for that visitor (remembered in their browser) until you change the banner’s content or they clear their browsing data. Leave dismissible off for something that should stay visible until you remove it from writedocs.json yourself.
notFound
Custom title and description for the page shown when a visitor hits a URL that doesn’t exist:
{
"notFound": {
"title": "Lost in the docs?",
"description": "That page doesn't exist — try the search bar or the sidebar."
}
}Both are optional; a site with no notFound config gets a plain, reasonable default. The page still renders inside your normal top navigation and footer, just without a sidebar or table of contents (there’s no active section to build them from).
Custom CSS and JS — just drop the file in
Any .css or .js file anywhere in your project — the root, docs/, snippets/, any folder you’ve made — loads automatically on every page. No writedocs.json field, no filename to register anywhere, no dedicated assets folder to put it in:
my-docs-site/
├── writedocs.json
├── theme.css ← loads automatically
└── docs/
├── getting-started.mdx
└── extra-styles.css ← also loads automaticallyThat’s the entire setup. Add a .css/.js file, edit it, refresh the page — it’s there. Rename it, delete it, move it to another folder — writedocs picks up whatever .css/.js files it finds anywhere in the project each time it builds or serves a page. Multiple files are fine; they load in alphabetical order by path.
If you’re coming from Astro and already keep a public/ folder in your project, a .css/.js file placed there is picked up too — it just loads as a linked <link rel="stylesheet">/<script src> instead of having its content inlined into the page, since a public/ file is already served as its own static asset. Same effect either way (the file loads on every page); this is only worth knowing if you’re specifically checking network requests or cache behavior. There’s no reason to create a public/ folder just for this — every folder works identically otherwise.
Because this checks the whole project, not just the root, any .css/.js file you keep around for another reason — a snippet’s own helper script, a stylesheet you’re drafting but haven’t wired up yet — will also load on every page. There’s no per-file opt-out short of giving it a different extension while you’re not using it.
scripts
For anything the drop-a-file convention above doesn’t cover — an external URL (a CDN-hosted widget, an analytics snippet), or inline code you’d rather keep in writedocs.json than as a separate file — scripts gives you explicit control:
{
"scripts": {
"head": [
{ "src": "https://example.com/widget.js" }
],
"body": [
{ "content": "console.log('loaded');" }
]
}
}Each entry is exactly one of src (an external URL or a local path) or content (inline code) — never both. scripts.head loads in <head>, before the page renders; scripts.body loads right before the page finishes, after everything else has already run — usually the right spot for a third-party snippet’s own install instructions. If both a project file and a writedocs.json entry try to affect the same thing, the project file wins — it’s the more specific, more locally-owned override.
A local root-relative path ("/widget.js", say, matching a file anywhere in your project) still works here too — it’s just no longer the only way to load one, since the drop-a-file convention above already covers every local file automatically. Pointing scripts at a real external URL (as above) is the case this still exists for.
See docs.json-examples/00-kitchen-sink/ in the Writedocs repo for a complete, buildable example combining all of this, including its own zero-config custom.css at the root.