Content
Site
A documentation-site layer on top of collections that arranges them into a navigable site with a nav tree, prev/next, search, a sitemap and a shell. Its read functions are safe to call from the browser while the corpus stays on the server.
On this page
A collection answers the question of what exists: a set of typed entries. Once you have more than a handful of pages, you also need to decide how they read as a site, which means a navigation tree, an order, prev/next links, a search index, a sitemap and a shell around each page. The site layer handles that. It builds directly on collections and lives in the same @ogygia/content package. This docs site is built with it, so every page you read here is arranged, navigated and searched by it. The Playground ↗ is a second example, with the stock shell, nine swappable themes, a version × locale switcher, a blog genre and an OpenAPI reference, all created by a single site() call.
You give site() your collections and an arrangement, and it creates small, browser-safe functions that return data for your routes and the shell to use. The corpus itself never crosses the wire. If all you need is a list and a body on one page, content() on its own is enough; reach for site() when you want a sidebar, “keep reading” links, a search box, llms.txt, or versioned or localized docs.
From zero
You can scaffold the whole thing (the site module, a content folder, routes and a DocsShell layout) with one command:
npx ogygia site initpnpm dlx ogygia site initThe scaffold is ordinary app code that you own: a folder() collection, a site() next to it, a catch-all route and a layout that mounts the shell. The rest of this page explains what those few files do.
The whole thing in one file
site() takes a single options object, in which outline is the only required key, and returns a Site. It lives in a .server.ts module next to the collections it arranges:
// server-only: the corpus can never reach a client bundle
import { site, links } from '@ogygia/content';
import { guides } from './collections.server';
export const docs = site({
outline: guides, // a bare collection arranges itself by convention (see below)
prevNext: 'graph', // "keep reading" follows the content graph
checks: [links()], // a broken in-prose link fails the build
data: {
title: 'Example',
description: 'Docs for Example.',
origin: 'https://example.dev' // absolute URLs for sitemap/llms when prerendered
}
});That is the complete configuration for this site. docs is a folder() collection, so its NN- filename prefixes already encode the order and its +meta.json sidecars name the sections. The outline reads that structure as data and builds the tree without any further configuration. When you want to arrange things by hand, you can point it at several collections, a pick() selection or explicit groups, as described under outline.
Collections belong in a server module
A collection’s loader is an eager glob of the whole compiled corpus, so define it in a .server.ts / .remote.ts file (or under src/lib/server/), where Kit keeps it out of client bundles. ogygia warns you at build time if you don’t; Content collections explains why.
What site() returns
The site you create (name it after what it is, such as docs or blog) is a set of read functions. Each one returns plain data, and none of them pulls the corpus into the browser:
| function | returns | used by |
|---|---|---|
docs.nav() | the sidebar tree, hrefs resolved | the shell / a nav remote |
docs.meta() | { nav, switcher, data } in one call | <DocsShell {meta}> |
docs.page(slug) | the view of one page: data, headings, body, prev/next | the page component |
docs.search(q) | ranked hits | a search remote / the worker |
docs.switcher(slug) | the version/locale picker (dimensioned sites) | <Switcher> |
docs.emit.* | GET handlers: sitemap, llms.txt, RSS, raw markdown, search index | +server.ts routes |
docs.check() | check findings, as data | vitest / CI |
docs.entries() | each leaf slug + declared old addresses | prerender entries |
docs.load | the 404 guard + alias 308s + per-page checks | the route’s load |
Because they return data, where they run is up to you. You can prerender the nav or fetch it per request, and query search on the server or ship the static index to a client worker. The remotes helper creates the common wire surface for you.
The wire layer
The collection holds the compiled corpus, which may be megabytes of markdown, and the site layer never serializes it. A page’s body crosses the wire as a held region ticket (baked HTML rather than source), and everything else is metadata. remotes() creates the crossing from site in a server-only .remote.ts, so the corpus stays on the server and the browser receives only the ticketed data:
// the wire layer
import { remotes } from '@ogygia/content/server';
import { docs } from './site.server';
export const { nav, meta, page, search } = remotes(docs, { base: '/docs' });Render it
The page component calls one function and renders the resulting view. The <Doc> component lays out the article, the on-this-page rail and prev/next from a docs.page() view, and the shell wraps it with the sidebar, the header and search:
<script>
import { page } from '$app/state';
import { Doc } from '@ogygia/content';
import * as docs from '$lib/site.remote';
const view = await docs.page(page.params.slug);
</script>
<Doc {view} />The layout mounts the shell over the meta remote, so the corpus never enters the layout’s module graph:
<script>
import DocsShell from '@ogygia/content/docs-shell';
import '@ogygia/content/theme.css';
import '@ogygia/content/shell.css';
import { meta } from '$lib/site.remote';
let { children } = $props();
const shellMeta = await meta();
</script>
<DocsShell meta={shellMeta} base="/docs" title="Docs">
{@render children()}
</DocsShell>Request context: previews and roles
Each read function takes an optional read context, a plain object that your context function derives from the request. Collections pass it into their filter, so one site can serve a public view and a preview view from the same corpus:
export const docs = site({
outline: guides,
context: ({ cookies }) => ({ preview: cookies?.get?.('preview') === '1' })
});
// a draft page exists only in the preview projection
const publicView = await docs.page('roadmap'); // null — filtered out
const draftView = await docs.page('roadmap', { context: { preview: true } });The empty context {} is the public, prerendered view. A site gated by context usually switches its remotes to query mode, so that each request derives its own context.
The full options bag
Every site() key at a glance; outline is the only required one.
| key | what it does |
|---|---|
outline | a collection, an outline([...]) spec, or dimensions(): the arrangement |
data | { title, description, origin }: site facts; emissions default from here |
base | default mount prefix for each read; a per-call { base } overrides |
prevNext | 'order' (default) or 'graph': how “keep reading” is chosen |
trail | 'site' (default) or 'group': whether prev/next crosses section boundaries |
checks | [links(), ...yours]: corpus invariants; see Checks |
components | markdown element overrides, as real component values; see Shell |
context | derive { preview, roles, … } from the request; threads into collection filters |
search | { engine }: swap the search engine; see Search |
redirects | old addresses per entry (the default reads data.redirect_from): baked 308 stubs |
The map
Each part of the site layer has its own page:
- Outline arranges collections into a tree, and covers the spec grammar, filename conventions, the address map and prev/next.
- Dimensions runs one site across a matrix of versions or locales, with a switcher and graceful fallback.
- Shell & components covers
Frame(headless),DocsShellandBlogShell(complete shells), plusDoc,Sidebar,Pagerand the theme. - Remotes is the wire layer:
nav,meta,pageandsearch, prerendered or live. - Search covers the search function, the static index, the on-device worker and the no-JS fallback.
- Emissions are the machine-facing outputs: sitemap,
llms.txt, RSS and raw markdown. - Checks are the link audit and custom checks that fail the build instead of the visitor.
Do / don’t
- Do keep
site()and its collections in a server module and create the wire crossing in a.remote.ts. That split is what keeps the corpus out of client bundles. - Do let filename conventions carry the structure (
NN-order,+meta.jsonlabels). The outline reads them as data, so the common case needs no arrangement config. - Don’t pass a collection’s bodies over the wire yourself. Let
docs.page()bake the body into a region ticket, which is the only form meant to cross. - Don’t use the site layer to render one standalone page; that is
content()plus<Region>. The site layer is the site around many pages.