Content
Content collections
One collection API. Define once, then load markdown, fetch a CMS, or stream a live feed. Data over the wire, bodies as components.
On this page
- Define the collection once, in a server module
- An entry is { id, data, meta, body }
- Data for the wire: list
- Rendering an entry the client fetched
- Load markdown (.svx / .md)
- Headings for free
- Islands inside markdown
- Custom sources (API / CMS)
- .yaml / raw text (roll your own)
- Live (push / SSE)
- Watch it push
- Do / don’t
See it all live
Playground ↗ is a second site built inside this one, on everything below: four markdown corpora, an OpenAPI reference rendered from JSON data, a dated blog, a version × locale switcher, and search. Every one is a content() collection.
Remote functions move data; for most sites the biggest data is the writing itself. ogygia/content gives SvelteKit content collections that fit the islands model. This docs site is built on it: every page you are reading is a .svx entry rendered through it.
You learn one API and reuse it everywhere. Define a collection once with content({ loader, schema }). Point its loader at a glob of markdown files, at a REST API or CMS, or at a source that pushes over time. The read surface (refs, get) never changes.
When to use
Reach for a collection whenever you have a set of content with typed frontmatter (docs, a blog, a changelog, product pages, a CMS feed) and you want a nav or index over the wire and full rendered bodies on the page.
One rule governs the whole surface: the boundary picks the representation. Stay on the server and you get the whole entry, body and all. Cross the wire and you get plain data.
| read | returns | where |
|---|---|---|
get(id) | { id, data, meta, body } — body is a held region | server only |
list({ map }) | a mapped data array | over the wire |
live.get / live.list | mapped data, re-emitted on change | over the wire (live) |
For a page assembled from typed blocks rather than a single markdown body, see Blocks. It is a separate, block-level API.
Define the collection once, in a server module
A collection’s loader sources the compiled corpus. Define it in a .server.ts file (or .remote.ts, or under src/lib/server/), so Kit mechanically guarantees no client module can import it. The corpus can never reach a browser bundle:
import { content } from 'ogygia/content';
import * as v from 'valibot';
export const docs = content({
// The loader is a compile macro — it owns the glob, you pass a literal path. Data + Meta are
// inferred, no annotations. See the loader macro for markdown/folder/json/git.
loader: import.meta.og.loader.markdown('../content/docs'),
schema: v.object({ title: v.string(), section: v.string() }),
filter: (e) => !e.data.draft // visibility, declared ONCE
});
import.meta.og.loader.*is the standard way to point a collection at static files. It owns theimport.meta.globplumbing, so you write only a literal path. It comes in four flavours (markdown,folder,json,git); see the loader macro for all four and the rules. The runtime builders it rewrites to (markdown(),folder(),json()) are still exported for the rare hand-built source.
ogygia warns you
Define a collection outside a server module and the build prints a warning naming the file. A client component that imports it would drag the entire corpus into its bundle (silently, and often megabytes). Keep the definition server-side and mint the wire crossing in a .remote.ts; that split is the whole defense.
The collection handle is still browser-safe by type: docs.refs and docs.get have no $app/server in their signatures. But the module that defines it is server-only because of the glob. Import the handle’s types anywhere; import the module only from the server.
filter is honored on every read path: refs, get, live, the wire remotes, and prerender inputs. A draft is invisible everywhere, including by direct URL. A per-remote filter can only narrow it further, never widen.
An entry is { id, data, meta, body }
get(id) is server-only and returns the whole entry:
data: validated frontmatter, typed from yourschema.meta: collector output, e.g.meta.headingsfor a table of contents.body: a held region you render with<Region>.
get returns null when the id is unknown or filtered out, and never throws. The caller decides the 404. Validate the slug in load:
// clean 404 for a missing entry
import { error } from '@sveltejs/kit';
import { docs } from '$lib/collections.server';
export const load = async ({ params }) => {
if (!(await docs.get(params.slug))) error(404, 'Not found');
};The collection is server-only, so the page component can’t import it. It gets the entry (including a baked body that legally crosses the wire) from a .remote.ts:
// mint a getter that bakes the body into a region ticket
import { query } from '$app/server';
import * as v from 'valibot';
import { docs } from './collections.server';
export const entry = query(v.string(), async (slug) => {
const e = await docs.get(slug);
if (!e) return null;
return { data: e.data, meta: e.meta, body: e.body ? await e.body : undefined };
});<!-- the body arrives as a ticket, islands inside wake -->
<script>
import { page } from '$app/state';
import { Region } from 'ogygia';
import { entry } from '$lib/docs.remote';
const view = (await entry(page.params.slug))!;
</script>
<h1>{view.data.title}</h1>
<Region of={view.body} /> <!-- a demo island in the .svx hydrates here -->Awaiting e.body bakes its SSR HTML into the region ticket, the one representation of a body that can cross the wire (a raw body is a same-pass live render and cannot be serialized). Prerender every entry from +page.server.ts:
export const prerender = true;
export const entries = async () => (await docs.refs()).map((e) => ({ slug: e.id }));Data for the wire: list
list and live.list mint real Kit remotes. Mint them in a .remote.ts (Kit requires every export there to be a remote); the consumer passes the args:
import { withRemotes } from 'ogygia/content/server';
import { docs } from './collections.server';
export const docNav = withRemotes(docs).list({ map: (e) => ({ slug: e.id, title: e.data.title }) });<script>
import { docNav } from '$lib/docs.remote';
const nav = await docNav();
</script>Rendering an entry the client fetched
get() is server-only. The body is a live component, so it stays in the SSR pass and renders with <Region of={entry.body} /> (above). When the client instead needs to render an entry it fetched over the wire (an index, a live feed, a search result), map that entry to a held region of one of your own with { region } components and return that. <Region> fetches and hydrates it: the one renderable that crosses the wire. See Held regions.
Load markdown (.svx / .md)
markdown() is the built-in source for a glob of .svx / .md files. It runs an opinionated pipeline: mdsvex for the body, Shiki for fences, pandoc-style heading ids, and a headings collector that fills each entry’s meta.headings.
Turn the pipeline on under the one plugin surface, with no separate preprocessor to register. An empty markdown: {} uses the defaults (Shiki github-light / github-dark via light-dark(), pandoc ## Title {#id} heading ids, collected h2–h4, wrapper class code-only):
ogygia({
content: {
markdown: {} // defaults: Shiki themes, heading ids, headings collector
}
});The full option list (themes, defaultColor, wrapperClass, headingIds, headings, langs, remarkPlugins, rehypePlugins) lives in the API reference. Every fenced code block on this site is highlighted by exactly this.
Headings for free
The preprocessor collects h2–h4 into meta.headings, auto-slugging any heading that lacks an explicit {#id}. get() hands that array back on the entry:
<script>
import { Region } from 'ogygia';
const entry = await docs.get(slug);
</script>
<Toc headings={entry.meta.headings} /> <!-- [{ depth, id, text }] -->
<Region of={entry.body} />The On this page rail beside these docs is built from exactly that array. Build your TOC from entry.meta.headings rather than re-parsing the body.
Islands inside markdown
Because ogygia’s transform runs after mdsvex, marked island imports work inside a .svx file, same as a .svelte page:
<script>
import Counter from '$lib/Counter.svelte' with { wake: 'load' };
</script>
<Counter start={3} />That is how the live demos in these docs run right inside the prose.
Custom sources (API / CMS)
A loader is not limited to a glob. Any object with refs / get is a source, so the same collection API covers a REST API or a CMS. Everything else (schema, filter, the wire remotes) is identical to a file-backed collection.
| Source | Write |
|---|---|
| Static files in repo | the loader macro — import.meta.og.loader.markdown · .folder · .json · .git |
| Blocks (CMS tree) | blocks(source, registry) with a regions() registry |
| A REST API | a { refs, get } source that fetches |
| Something that pushes | add live() — a change signal — + live.list / live.get |
A source is two async methods. refs() lists the corpus as lightweight metadata; get(id) fetches one full entry:
import { content } from 'ogygia/content';
const press = content({
schema,
loader: {
async refs() {
const rows = await fetch('https://cms/posts').then((r) => r.json());
return rows.map((r) => ({ id: r.slug, data: r }));
},
async get(id) {
const row = await fetch(`https://cms/posts/${id}`).then((r) => r.json());
return row ? { id, data: row } : null;
}
}
});get(id) fetches one post on demand, not a full list. Mint the wire remotes in a .remote.ts, exactly as with a file-backed collection:
import { withRemotes } from 'ogygia/content/server';
export const list = withRemotes(press).list({ map: (e) => ({ id: e.id, title: e.data.title }) });A fetching source defaults to query mode, resolved at request time, not build.
.yaml / raw text (roll your own)
ogygia ships markdown(), json(), and blocks(), but not a .yaml or raw-text loader. Its built-in YAML is frontmatter-only, nothing more. Both are a few lines over a glob with defineSource (exactly how the built-ins are made), so bring whatever parser you like:
import { content, defineSource, toRawSource } from 'ogygia/content';
import { parse as parseYaml } from 'yaml'; // npm i yaml
// .yaml files → data-only entries
export const team = content({
schema,
loader: defineSource(
toRawSource(import.meta.glob('./team/*.yaml', { query: '?raw', import: 'default', eager: true })),
(raw) => ({ data: parseYaml(raw as string) })
)
});
// raw text files → `{ body }`
export const snippets = content({
loader: defineSource(
toRawSource(import.meta.glob('./snippets/*.txt', { query: '?raw', import: 'default', eager: true })),
(raw) => ({ data: { body: raw as string } })
)
});The second arg is a format: (rawValue, id) => { data, meta?, body? }. That is the entire contract a source builder wraps.
Live (push / SSE)
Add live(), a change signal. Yield whenever your feed changes and the collection re-reads. Pair it with live.list / live.get, which re-emit mapped snapshots on every change:
let posts = [];
const press = content({
schema,
loader: {
async refs() { return posts.map((p) => ({ id: p.id, data: p })); },
async get(id) { const p = posts.find((p) => p.id === id); return p ? { id: p.id, data: p } : null; },
async *live() {
for await (const next of subscribeToCms()) { posts = next; yield 1; }
}
}
});
export const headlines = withRemotes(press).live.list({ map: (e) => ({ id: e.id, title: e.data.title }) });Yield anything to re-read the whole source, or a string[] of changed ids for an incremental reload (get(id) each), for a large collection where a full re-list is wasteful.
Watch it push
Below, a query.live yields a rendered held region every second. The server renders each tick and pushes the HTML down the channel; the client morphs it in place, with no client data code and no per-tick fetch.
Live sources need a long-lived server. On short-lived serverless functions an in-memory stream is per-isolate. Document it, and degrade gracefully rather than promising continuity.
Do / don’t
- Do define the collection once in a
.server.tsmodule, then reach it from server code (get) and mint the wire (list/ a body-baking getter) in a.remote.ts. - Do do your 404 in
loadwitherror(), not in the component’s top-levelawait. - Do leave
markdown: {}at defaults to start; the built-in Shiki themes and heading collector cover most sites. - Do build your TOC from
entry.meta.headingsrather than re-parsing the body. - Do map wire entries to a held region of your own
with { region }component when the client must render them;<Region>is the one renderable that crosses the wire. - Don’t register a separate mdsvex plugin; configuration lives entirely under
ogygia({ content: { markdown } }). - Don’t expect a compiled body from a CMS row. Those sources deliver data; render it through a held region.
- Don’t promise live continuity on serverless; an in-memory stream is per-isolate there.
A collection knows what exists. site() decides how it reads: nav, search, shell, and more.