Skip to content

Content

Content collections

Define a collection once, then load markdown, read a CMS or stream a live feed through the same API. Data crosses the wire, and bodies render as components.

On this page

See it all live

Playground ↗ is a second site, built inside this one, that uses every feature on this page: four markdown corpora, an OpenAPI reference rendered from JSON data, a dated blog, a version × locale switcher, and search. Each of them is a content() collection.

Remote functions move data, but on most sites the largest body of data is the writing itself: docs, blog posts, changelogs, product pages, or entries in a CMS. You usually want two things from that writing. You want a navigation or an index that the client can fetch, and you want full rendered bodies on the page, ideally with interactive islands inside them. @ogygia/content gives SvelteKit content collections that do both and fit the islands model. This docs site is built on it, and every page you read here is a .svx entry rendered through it.

You define a collection once with content({ loader, schema }). The loader can point at a glob of markdown files, at a REST API or CMS, or at a source that pushes changes over time, and the way you read the collection (refs, get) stays the same whichever you choose.

What you get back depends on where you read it, because the boundary picks the representation. On the server you get the full entry, including its body, and across the wire you get plain data:

readreturnswhere
get(id){ id, data, meta, body }; the body is a held regionserver only
list({ map })a mapped data arrayover the wire
live.get / live.listmapped data, emitted again on each changeover the wire (live)

If your page is assembled from typed blocks rather than a single markdown body, use Blocks, which is a separate, block-level API.

Installing

Collections ship as their own package. Install it and list its plugin beside ogygia():

pnpm add @ogygia/content
import { ogygia } from 'ogygia/vite';
import { content } from '@ogygia/content/vite';

plugins: [ogygia(), content({ markdown: {} }), sveltekit()]

The markdown option turns the markdown pipeline on; a collection over JSON, a CMS or an API needs only content(). On the dev server, the plugin reloads the page when a file under src/content changes; content kept elsewhere can be watched too, with the watchDirs option described in the API reference. For markdown, svelte.config.js also takes preprocess() and extensions() from @ogygia/content/vite (see Install). To get the macro types (import.meta.og.loader, og.code, og.md), add one line to src/ogygia.d.ts:

/// <reference types="@ogygia/content/types" />

Define the collection once, in a server module

A collection’s loader holds the compiled corpus, so define the collection in a .server.ts file (or a .remote.ts, or under src/lib/server/). Kit then guarantees that no client module can import it, which means the corpus can never end up in 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 takes care of the import.meta.glob plumbing, so you only write a literal path. It comes in four kinds (markdown, folder, json and git), all described on the loader macro page along with its rules. The runtime builders it rewrites to (markdown(), folder(), json()) remain exported for the rare source you build by hand.

ogygia warns you

If you define a collection outside a server module, the build prints a warning that names the file. A client component that imported it would silently pull the whole corpus into its bundle, which is frequently megabytes. Keep the definition on the server and create the wire crossing in a .remote.ts; that split is the whole defence.

The collection handle is safe for the browser at the type level, because docs.refs and docs.get have no $app/server in their signatures. The module that defines it is server-only, though, because of the glob. You can import the handle’s types anywhere, but import the module only from the server.

The collection applies filter on every read path: refs, get, live, the wire remotes and prerender inputs. A draft is therefore invisible everywhere, including at its direct URL. A per-remote filter can narrow this further but never widen it.

An entry is { id, data, meta, body }

get(id) is server-only and returns the full entry:

  • data: the validated frontmatter, typed from your schema.
  • meta: output from the collectors, for example meta.headings for a table of contents.
  • body: a held region that you render with <Region>.

get returns null when the id is unknown or filtered out, and it never throws, so the caller decides when to answer 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');
};

Because the collection is server-only, the page component cannot import it. Instead it gets the entry from a .remote.ts, with a baked body that is allowed to cross the wire:

// 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, and that is the only form of a body that can cross the wire; a raw body is a live render within the same pass and cannot be serialized. To prerender each entry, add this to +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 create real Kit remotes, so create them in a .remote.ts, where Kit requires every export to be a remote. The consumer passes the arguments:

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

A body from get() is a live component, so it stays in the SSR pass and renders with <Region of={entry.body} /> as shown above. When the client has to render an entry it fetched over the wire (an index, a live feed, a search result), map the entry to a held region of one of your own with { region } components and return that instead; <Region> fetches and hydrates it. Held regions explains how this works.

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 code fences, pandoc-style heading ids, and a headings collector that fills each entry’s meta.headings.

You turn the pipeline on with the content() plugin from @ogygia/content/vite, listed beside ogygia(). An empty markdown: {} uses the defaults: Shiki github-light / github-dark through light-dark(), pandoc ## Title {#id} heading ids, collected h2–h4, and the wrapper class code-only.

import { content } from '@ogygia/content/vite';

export default defineConfig({
  plugins: [
    ogygia(),
    content({
      markdown: {} // defaults: Shiki themes, heading ids, headings collector
    }),
    sveltekit()
  ]
});

The full option list (themes, defaultColor, wrapperClass, headingIds, headings, langs, remarkPlugins, rehypePlugins) is in the API reference. Every fenced code block on this site is highlighted by this pipeline.

Headings without extra work

The preprocessor collects h2–h4 into meta.headings and generates a slug for each heading that has no explicit {#id}. get() returns that array 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 that array. Build your own table of contents from entry.meta.headings rather than parsing the body again.

Islands inside markdown

ogygia’s transform runs after mdsvex, so marked island imports work inside a .svx file exactly as they do in 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 inside the prose.

Custom sources (API / CMS)

A loader does not have to be a glob. Any object with refs / get is a source, so the same collection API covers a REST API or a CMS, and everything else (the schema, filter, the wire remotes) works exactly as it does for a file-backed collection.

SourceWrite
Static files in repothe loader macro: import.meta.og.loader.markdown · .folder · .json · .git
Blocks (CMS tree)blocks(source, registry) with a regions() registry
A REST APIa { refs, get } source that fetches
Something that pushesadd live() (a change signal) plus live.list / live.get

A source is two async methods: refs() lists the corpus as light metadata, and 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 a single post on demand rather than the whole list. Create the wire remotes in a .remote.ts, the same way as for 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 source that fetches defaults to query mode, which means it is resolved at request time rather than at build time.

.yaml / raw text (roll your own)

ogygia ships markdown(), json() and blocks(), but not a .yaml or raw-text loader, and its built-in YAML support covers frontmatter only. Either one takes a few lines over a glob with defineSource, which is also how the built-in sources are made, so you can bring whichever parser you prefer:

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 argument is a format, (rawValue, id) => { data, meta?, body? }, and that is the whole contract a source builder wraps.

Live (push / SSE)

Add live(), a change signal: yield whenever your feed changes, and the collection reads the source again. Pair it with live.list / live.get, which emit fresh mapped snapshots on each 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 }) });

Yielding any value makes the collection read the whole source again. If you yield a string[] of changed ids instead, it reloads incrementally by calling get(id) for each, which is the better choice for a large collection where listing everything again would be wasteful.

Watch it push

In the demo below, a query.live yields a rendered held region every second. The server renders each tick and pushes the HTML down the channel, and the client morphs it in place, with no client data code and no fetch per tick.

connecting…

server pushes rendered HTML · no client data code

Live sources need a long-lived server. On short-lived serverless functions an in-memory stream exists only within one isolate, so if you deploy there, say so in your own docs, degrade gracefully, and don’t promise continuity.

Do / don’t

  • Do define the collection once in a .server.ts module, reach it from server code (get), and create the wire crossings (list or a body-baking getter) in a .remote.ts.
  • Do answer 404 in load with error(), not in the component’s top-level await.
  • Do start with markdown: {} at the defaults; the built-in Shiki themes and heading collector cover most sites.
  • Do build your table of contents from entry.meta.headings instead of parsing the body again.
  • Do map wire entries to a held region of your own with { region } component when the client must render them, because <Region> is the one renderable that crosses the wire.
  • Don’t register a separate mdsvex plugin; all configuration lives under content({ markdown }).
  • Don’t expect a compiled body from a CMS row. Those sources deliver data, which you render through a held region.
  • Don’t promise live continuity on serverless, where an in-memory stream exists only within one isolate.

A collection knows what exists, and site() decides how it reads: navigation, search, the shell and more.