doc-kit generate writes a static site to one directory: the HTML pages,
their scripts and stylesheets, and every other output you target. To publish
it, build with your production settings, then upload that directory to any
static host. This guide covers both, with recipes for common hosts.
In your configuration file, set where the site will live and which version it documents:
export default { target: ['html', 'orama-db', 'json', 'sitemap', 'llms-txt'], global: { project: 'Kittens', version: '1.0.0', input: ['docs/**/*.md'], output: 'out', baseURL: 'https://kittens.example.com', minify: true, }, };
Then build:
npx doc-kit generate
Three settings matter most for a published site:
baseURLis the URL the site is published at, without a trailing slash. The sitemap,llms.txtand absolute links are built from it.versionis the version your docs describe, shown in page titles. Without it, titles show the version of Node.js running the build.minifyminifies the pages' HTML, and writes the JSON outputs, the search index included, without whitespace. Scripts and stylesheets are minified either way. The--minifyflag sets it too.
Without baseURL, sitemap.xml and llms.txt are still written, but every
URL in them starts with a literal {baseURL}.
The configuration reference lists the other global options.
npx doc-kit preview
preview serves the output directory (./out by default) on
http://localhost:3000. Without options, it serves what is already there; pass
any generate option, such as --config-file doc-kit.config.mjs, and it builds
first. --port and --host change where it listens; the
CLI reference has the details.
Always open the pages over HTTP, never straight from disk. The pages load
their scripts as JavaScript modules, request their stylesheet with CORS, and
fetch the search index. Browsers block all three for file:// pages, which
then show up unstyled and inert.
The preview server is for checking a build. It doesn't compress responses or set cache headers, so leave those to your host.
Besides its pages and assets/, the site links to files that other generators
write, or that you copy in yourself:
| File | Used by | How it gets there |
|---|---|---|
orama-db.json | The search box | Add orama-db to target. |
<page>.json | The "View as JSON" link on each page | Add json to target. |
<page>.md | The "View as MD" link on each page, llms.txt and llms-full.txt | Copy your Markdown, see below. |
404.html | Your host, for URLs that don't exist | Written by default. |
The search box only appears when the search index is built in the same run as
the pages. If you build the index separately, set html.showSearchBox to
true.
To copy files into the output as part of the build, list them in
pathsToCopy, relative to the working directory. A string copies a path under
its own name ('public' lands in out/public/), and an object maps each
source to a path inside the output. The default list is
['assets', 'public', 'static'], and setting pathsToCopy replaces it rather
than adding to it. This copies your Markdown next to the pages:
export default { global: { // docs/kittens.md is published as kittens.md, next to kittens.html pathsToCopy: [{ docs: '.' }], }, };
Each page is written as <name>.html, and the links doc-kit makes between
your pages carry that extension, so the site works on any static host without
configuration. Many hosts also serve a page at its extensionless URL
(/kittens), and some redirect /kittens.html there, such as Vercel with
cleanUrls and Cloudflare Workers by default. The site follows those
redirects when it navigates, so either setup works.
Type links are the exception: doc-kit uses the paths in your type map as
written. Those that @doc-kit/typedoc writes have no
extension, so they need a host that serves /kittens from kittens.html.
If your host redirects to extensionless URLs, list those in the sitemap, so search engines don't crawl redirects:
export default { sitemap: { // `{path}` is the page's path without its extension, starting with `/` pageURL: '{baseURL}{path}', }, };
The sitemap and llms.txt
pages list their URL templates and defaults.
Pages reach each other and their assets through relative URLs, so a site
works under any path, such as a GitHub Pages project site at
https://<owner>.github.io/<repo>/. Two pages load their assets from the root
of the domain instead: 404.html, which hosts serve at any missing URL, and
all.html. Turn on useAbsoluteURLs, and they load them from baseURL:
export default { global: { baseURL: 'https://owner.github.io/repo', }, html: { useAbsoluteURLs: true, }, };
Every link then points at baseURL, so when you preview that build locally,
following a link takes you to the published site. The logo in the default
navigation bar links to the root of the domain either way; to change that,
replace #theme/Navigation (see Customizing the site).
Every file the html generator writes to assets/ (scripts, stylesheets,
fonts) is named after a hash of its content, so a changed file always gets a
new name. Serve that directory with a long-lived, immutable cache, and let
everything else (the pages, the search index) revalidate:
/assets/* Cache-Control: public, max-age=31536000, immutable
Most hosts revalidate every file on every load by default, which costs a request per asset each time someone opens the site. The Netlify, Vercel and Cloudflare Workers host recipes below set this header (GitHub Pages doesn't let you). Within a visit, the site swaps pages in client-side in browsers that support it, so moving between pages doesn't load the assets again.
Keep your own files out of a project-level assets/ directory. The default
pathsToCopy copies it into out/assets/ as is, without hashed names, so a
changed file would stay cached for a year.
Pages, scripts and the search index are text, and shrink a lot when served compressed (gzip, brotli or zstd). Cloudflare, Vercel, Netlify and GitHub Pages compress on their own. If you serve the output yourself, turn compression on:
- nginx:
gzip on;compresses HTML; add the other types withgzip_types text/css text/javascript application/javascript application/json image/svg+xml;. - Caddy:
encode zstd gzip. - Apache: with
mod_deflate,AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json. - Amazon S3: serve the bucket through CloudFront, with Compress objects automatically on.
Each recipe assumes a doc-kit.config.mjs that writes to out, with
@doc-kit/cli (and @doc-kit/generator-react, for the site) in your
devDependencies.
In your repository's settings, under Pages, set the source to GitHub Actions, then add a workflow that builds the site and deploys it:
name: Publish docs on: push: branches: [main] permissions: contents: read concurrency: group: pages cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - run: npm ci - run: npx doc-kit generate - uses: actions/upload-pages-artifact@v5 with: path: out deploy: needs: build runs-on: ubuntu-latest permissions: pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment uses: actions/deploy-pages@v5
GitHub Pages serves 404.html for missing pages, but doesn't let you set
response headers, so the assets keep its default caching. A project site
lives under a subpath; see Hosting under a subpath.
Netlify installs your dependencies, runs the build command and publishes the
publish directory:
[build] command = "npx doc-kit generate" publish = "out" [[headers]] for = "/assets/*" [headers.values] Cache-Control = "public, max-age=31536000, immutable"
{ "buildCommand": "npx doc-kit generate", "outputDirectory": "out", "cleanUrls": true, "headers": [ { "source": "/assets/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] } ] }
cleanUrls serves each page without its .html and redirects the .html URL
there. Drop it to keep the extensions; otherwise, set the sitemap's pageURL
to match (see Page URLs).
Cloudflare Workers serves the output as static assets. Its response headers
come from a _headers file at the root of the output, which pathsToCopy
can put there:
{ "name": "kittens-docs", "compatibility_date": "2026-10-01", "assets": { "directory": "./out", "html_handling": "auto-trailing-slash", "not_found_handling": "404-page", }, }
/assets/* Cache-Control: public, max-age=31536000, immutable
export default { global: { // Replaces the default list: copies public/ (with _headers) to the root of // out/, and docs/ for the .md files the pages link to pathsToCopy: [{ public: '.', docs: '.' }], }, };
auto-trailing-slash serves /kittens from kittens.html and redirects
/kittens.html there, and 404-page answers missing URLs with 404.html.
As with Vercel, set the sitemap's pageURL to match (see
Page URLs). Build, then deploy with Wrangler:
npx doc-kit generate npx wrangler deploy
The version selector in the sidebar lists the releases in global.changelog
(or --changelog); a site built without one has no selector. Build each
version on its own, with its version and baseURL, and publish it where
html.pageURL says it lives:
export default { global: { version: '3.0.0', baseURL: 'https://kittens.example.com/v3.x', changelog: './CHANGELOG.md', }, html: { // Where each version's copy of a page lives pageURL: 'https://kittens.example.com/{version}{path}.html', }, };
In pageURL, {version} becomes v<major>.x, or v<major>.<minor>.x for a
release whose minor version isn't 0 (v3.x, v2.1.x), and {path} the
page's path.
changelog takes a URL or a path to a Markdown file that lists releases one
per line, in the format of the
Node.js changelog:
* [Node.js 3.0](https://kittens.example.com/v3.x/) Current * [Node.js 2.1](https://kittens.example.com/v2.1.x/) **Long Term Support**
Each line needs the * bullet, the literal Node.js label, whatever your
project is called, and some notes after the link. Lines in any other shape are
skipped without an error. The link's target is ignored, since the selector
builds its links from html.pageURL, and notes that mention "Long Term
Support" mark an LTS release.
In a JavaScript configuration file, you can list the releases instead, each
as { version, isLts, isCurrent } with a version from the semver package.
isCurrent adds "(Current)" to the release's label in the selector, which the
Markdown file can't do:
import { coerce } from 'semver'; export default { global: { changelog: [ { version: coerce('3.0.0'), isLts: false, isCurrent: true }, { version: coerce('2.1.0'), isLts: true }, ], }, };
Docs built for an older release keep the list they were built with. To update
it without rebuilding them, point html.remoteConfigUrl at a JSON file whose
versions replace the built-in list; the
website generator describes its
shape.
Once your site is online, Creating generators shows how to add outputs doc-kit doesn't ship, such as an RSS feed.