Skip to content
Guide

content

Product docs, guides and changelogs start as markdown files, and turning them into pages with a sidebar, highlighted code and search is a project of its own. @kitr/content renders each folder of markdown at build time into typed JSON that your routes load with loadPage('docs', slug). It builds on unified and Shiki, and adds collections, a nav.json sidebar, callouts, package-manager command blocks, search and llms.txt for coding agents.

Rendering happens in kitr generate, so the browser downloads HTML and JSON, never a markdown parser. The components run on both sides. The examples follow Acme's docs at docs.acme.com. For records that users create and edit, use a database with db instead.

Install

kitr add content

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

Everything kitr add changes

What kitr add does:

  • Installs @kitr/content and adds it to packages in kitrrc.ts. It registers no provider.
  • Turns on kitr generate, so kitr dev, kitr build and kitr typecheck render the markdown into .kitr/content/.
  • Writes config/content.ts (shown in Configure) and regenerates config/index.ts.
  • Writes content/docs/nav.json and a first page, content/docs/getting-started.md.
  • Adds the content Vite plugin, which serves llms.txt in dev and writes it in the build.
  • Adds a ## kitr: content section to AGENTS.md on frontmatter, nav.json, loadPage() and the markdown extras.

It asks no questions and adds no env variables. The docs kit includes content and adds the sidebar, search and pages.

kitr remove content removes the packages entry, the dependency and the AGENTS.md section. It leaves content/ and your routes in place.

Configure

kitr add content writes this file:

config/content.ts
import { defineConfig } from '@kitr/content'

export default defineConfig({
  collections: {
    // content/docs/*.md, served at /docs/<slug>; nav.json orders and groups them.
    docs: { source: 'content/docs', url: '/docs', nav: 'nav.json' },
  },
  // Serve and build llms.txt, llms-full.txt and <url>.md of every page, for coding agents.
  llms: true,
})

kitr generate runs this file with the project's TypeScript, so it can import helpers and compute values. The app never reads it at run time.

Option Default Description
collections none Collections by name; the name is what loadPage(name, slug) and nav[name] use.
collections.<name>.source none The folder of *.md files, from the project root.
collections.<name>.url none The URL prefix: /docs serves cli.md at /docs/cli.
collections.<name>.nav none A JSON file in source that orders and groups pages.
collections.<name>.title the name, capitalised The group title when there is no nav.
llms false Write llms.txt, llms-full.txt and <url>.md; true or { title, description, url }.
llms.title package name as words The # heading of llms.txt: acme-docs becomes Acme Docs.
llms.description none The summary line under the heading.
llms.url relative links The site origin, such as https://docs.acme.com, for absolute links.
code.themes github-light-high-contrast, github-dark Shiki themes for light and dark.
code.langs ts, tsx, js, jsx, json, sh, bash, css, html, md, yaml, diff Languages to load; others render as plain text.
editUrl none The start of an "Edit this page" link; the file path is appended.

There are no env variables.

Render a page

A page is a markdown file with title and description frontmatter. Acme's guide to invoices:

---
title: Send an invoice
description: Create an invoice for a team and email it.
---

# Send an invoice

Invoices belong to a team. Create one from the billing page.

## Email it

Grace gets a copy at [email protected].

List its slug in content/docs/nav.json, then load it in the route's loader. On the server and in the client, loadPage() imports only that page's JSON:

app/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import { notFound } from '@kitr/router'
import router from '@kitr/router/routes'
import { seo } from '@kitr/seo'
import { loadPage } from '#kitr/content'

export const routes = defineRoutes([router], (r) =>
  r.page('/docs/$slug', {
    loader: async ({ params }) => {
      const page = await loadPage('docs', params.slug)
      if (!page) throw notFound()
      return page
    },
    head: (ctx) => seo({ type: 'article' }, ctx),
    lazy: () => import('./pages/docs/slug'),
  }),
)

Render it with <DocBody> in a component:

app/pages/docs/slug.tsx
import { DocBody, Toc } from '@kitr/content/react'
import { getRouteApi } from '@kitr/router'

const route = getRouteApi('/docs/$slug')

export default function DocPage() {
  const page = route.useLoaderData()
  return (
    <main className="mx-auto flex max-w-5xl gap-10 px-4 py-12">
      <article className="min-w-0 flex-1">
        <h1 className="text-3xl font-bold">{page.title}</h1>
        <DocBody page={page} />
      </article>
      <Toc page={page} />
    </main>
  )
}

/docs/send-an-invoice shows the title, the body and an "On this page" list with "Email it" marked while you read it. The leading # heading is left out of the body because the layout renders page.title. A slug with no file returns null, and the loader's notFound() renders the route's not-found component.

Order and group pages

nav.json lists the groups of the sidebar and the pages in each, in order. A page missing from it is not built:

[
  {
    "title": "Billing",
    "description": "Invoices, plans and payments.",
    "pages": ["send-an-invoice", { "slug": "plans", "title": "Plans and seats" }]
  }
]

An entry is a slug or { slug, title?, description?, source? }; source points at a file outside the folder, so two collections can share a page. A collection without nav lists every *.md in one group, sorted by the frontmatter order, then by title.

nav.docs from #kitr/content is that file, typed. Slug<'docs'> is the union of its slugs, so a link to a missing page fails kitr typecheck:

app/components/billing-links.tsx
import { Link } from '@kitr/router'
import { nav, type Slug } from '#kitr/content'

const featured: Slug<'docs'> = 'send-an-invoice'

export function BillingLinks() {
  return (
    <ul>
      {nav.docs.flatMap((group) => group.items).map((item) => (
        <li key={item.slug}>
          <Link to="/docs/$slug" params={{ slug: item.slug }}>
            {item.slug === featured ? <strong>{item.title}</strong> : item.title}
          </Link>
        </li>
      ))}
    </ul>
  )
}

Highlight code

Shiki highlights every fenced block for both themes at build time. The first line names the file when it is a path comment, and it becomes the block's caption:

server/billing/invoice.ts
export const total = (lines: { amount: number }[]) =>
  lines.reduce((sum, line) => sum + line.amount, 0)

The page renders a server/billing/invoice.ts caption above the code, without the comment line, and a Copy button. The CSS variables switch colours when <html data-theme="dark"> is set, which theme does. Add languages or change themes in code; a fence in a language that is not loaded renders as plain text.

Show commands for every package manager

A sh block whose lines all start with kitr becomes a command block instead of HTML:

kitr add mail

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

<DocBody> renders it as a plain code block. Pass renderCommand to show a picker; the registry's command component, which the docs kit installs, shows npm, pnpm, yarn and bun tabs and remembers the choice:

app/pages/docs/body.tsx
import type { Page } from '@kitr/content'
import { DocBody } from '@kitr/content/react'
import { Command } from '#components/ui/command'

export function Body({ page }: { page: Page }) {
  return <DocBody page={page} renderCommand={(cmd) => <Command cmd={cmd} />} />
}

Write callouts, steps and next steps

Markdown extras that render without any component:

Write Renders as
> [!NOTE], > [!TIP], > [!WARNING], > [!SUMMARY] A callout; text after the marker is its title.
## 1. Create the team A numbered step; the number leaves the heading.
A last ## Next steps with one list of links Cards, one per link.
[the CLI](./cli.md#flags) A link to /docs/cli#flags.
A link to https:// Opens in a new tab with rel="noreferrer".
A table A table that scrolls sideways on phones.

Every h2 and h3 gets an id, and the h2s and h3s fill page.toc. Link between pages by file name, so links keep working in your editor and on GitHub.

Search the pages

kitr generate writes a search index with one entry per page and per h2 or h3. search() ranks it: titles first, then headings, then text, at most two hits per page:

app/search.ts
import { search } from '@kitr/content'
import { loadSearch } from '#kitr/content'

export async function find(query: string) {
  const entries = await loadSearch()
  return search(entries, query, 5)
}

// await find('invoice')
// => [{ url: '/docs/send-an-invoice', title: 'Send an invoice', section: 'Billing', heading: '', text: '...' }]

loadSearch() downloads the index on first use only. The registry's search-dialog component wraps this in a dialog that opens on Cmd+K.

Serve llms.txt

With llms: true, kitr dev answers /llms.txt, /llms-full.txt and every page's markdown at <url>.md, and kitr build writes the same files next to the HTML:

curl http://localhost:3000/llms.txt
# # Acme Docs
#
# ## Billing
#
# - [Send an invoice](/docs/send-an-invoice.md): Create an invoice for a team and email it.

Coding agents read the markdown instead of scraping HTML. Set llms.url to make the links absolute.

Testing

Vitest runs kitr generate before the tests, so #kitr/content is current. Load a page and render it like any component:

test/docs.test.tsx
import { search } from '@kitr/content'
import { DocBody } from '@kitr/content/react'
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import { loadPage, loadSearch } from '#kitr/content'

it('renders the getting started page', async () => {
  const page = await loadPage('docs', 'getting-started')
  expect(page).toMatchObject({ url: '/docs/getting-started', title: 'Getting started' })
  const view = render(<DocBody page={page!} />)
  expect(view.getByRole('heading', { name: 'Add a page' })).toBeTruthy()
})

it('finds the page by its heading', async () => {
  const [hit] = search(await loadSearch(), 'add a page')
  expect(hit?.url).toBe('/docs/getting-started#add-a-page')
})

it('has no page for an unknown slug', async () => {
  expect(await loadPage('docs', 'no-such-page')).toBeNull()
})

<DocBody> works without a router; inside one, it turns clicks on internal links into client-side navigation.

Next steps