On this page

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:

Three settings matter most for a published site:

  • baseURL is the URL the site is published at, without a trailing slash. The sitemap, llms.txt and absolute links are built from it.
  • version is the version your docs describe, shown in page titles. Without it, titles show the version of Node.js running the build.
  • minify minifies the pages' HTML, and writes the JSON outputs, the search index included, without whitespace. Scripts and stylesheets are minified either way. The --minify flag sets it too.
Warning

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.

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:

FileUsed byHow it gets there
orama-db.jsonThe search boxAdd orama-db to target.
<page>.jsonThe "View as JSON" link on each pageAdd json to target.
<page>.mdThe "View as MD" link on each page, llms.txt and llms-full.txtCopy your Markdown, see below.
404.htmlYour host, for URLs that don't existWritten 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:

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.

Caution

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 with gzip_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.