Content collections
File-based, schema-typed markdown content — defineCollection over content/<name>/**/*.md, an isomorphic getCollection/getEntry, locale trees with fallback, headings for TOCs, data collections, references, and RSS feeds — without installing a markdown dependency.
A content collection turns a folder of markdown files into typed, rendered
content: you declare the frontmatter schema in code, and getCollection() /
getEntry() hand you decoded data plus server-rendered HTML with
syntax-highlighted code fences. The markdown engine lives in the framework —
do not install your own marked / remark / shiki; a second pipeline
drifts from the one your artifacts, feeds and templates already use.
A blog in 20 lines
One collection file, one markdown file, one page:
// src/collections/posts.collection.ts
import { Schema } from 'effect'
import { defineCollection } from '@voltro/content'
export const posts = defineCollection({
name: 'posts',
directory: 'content/posts',
schema: Schema.Struct({ title: Schema.String, date: Schema.String }),
})
export type Post = Schema.Schema.Type<typeof posts.schema>// src/pages/blog/[slug]/page.tsx
import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
import { useLoaderData } from '@voltro/web'
import { posts, type Post } from '../../../collections/posts.collection'
export const renderMode = 'static' as const
export const getStaticPaths = async () =>
(await getCollection(posts.name)).map((e) => ({ params: { slug: e.slug } }))
export const loader = async ({ params }: { params: { slug: string } }) =>
await getEntry<Post>(posts.name, params.slug)
export default function Post() {
const post = useLoaderData<ContentEntry<Post> | null>()
if (!post) return <main>Not found</main>
return <article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
}Drop content/posts/hello.md with title: + date: frontmatter and the
build pre-renders /blog/hello — highlighted code fences included.
How it stays out of your bundle
The loader is isomorphic. At build/SSR time it reads the filesystem and
renders markdown (shiki runs on the server only). The build also emits JSON
artifacts under dist/assets/content/<name>[.<locale>]/… — an index (slugs +
frontmatter, no bodies) and one file per entry (rendered HTML + headings). On
an SPA navigation, the CLIENT branch of getCollection/getEntry fetches
those artifacts. The result: no markdown engine, no highlighter, and no
content bodies in your JavaScript bundle. voltro dev serves the same
artifact shapes on demand and invalidates them when a content/** file
changes.
Compiled SSR keeps the lazy server rendering pipeline in the server bundle. It still reads the collection's content files at runtime, so include those files in deployments that use content loaders during SSR. Browser navigation continues to use the generated JSON artifacts.
Frontmatter is a schema, and violations fail the build
The schema is an effect/Schema struct decoded per file. A missing or
mistyped field is a build error naming the file — not a page that renders
undefined. Numbers in frontmatter arrive as strings; use
Schema.Union(Schema.NumberFromString, Schema.Number) for numeric fields.
getCollection<A> returns entries whose data is the schema's inferred
type — no casts.
Slugs come from the path
content/posts/hello.md → hello; nested folders stay in the slug
(database/joins.md → database/joins). Two files resolving to one slug
(a rename that left both) is a build error.
Locale trees + fallback
A collection with i18n treats the first path segment as the locale:
export const docs = defineCollection({
name: 'docs',
directory: 'content/docs',
schema: Schema.Struct({ title: Schema.String }),
i18n: { locales: ['en', 'de'], defaultLocale: 'en', missing: 'fallback' },
})getCollection('docs', { locale: 'de' }) reads the de/ tree. A slug missing
in the requested locale is served from the default tree with
fallback: true on the entry (render an "untranslated" banner off it) — or
omitted entirely with missing: 'missing'. Incomplete translations are the
normal case; decide the policy per collection instead of improvising per page.
Headings as data
Every rendered entry carries headings: [{ depth, slug, text }] — the TOC
input. The slugs are the SAME ids stamped on the rendered <h2 id="…">
elements, so sidebar anchors never drift from the body. For a TOC without a
render pass, extractHeadings(markdown) (from @voltro/content/markdown)
computes the same data synchronously.
Data collections
kind: 'data' reads .json files instead of markdown — the authors.json
case. Each file decodes whole against the schema; there is no render path:
export const authors = defineCollection({
name: 'authors',
directory: 'content/authors',
kind: 'data',
schema: Schema.Struct({ name: Schema.String, url: Schema.String }),
})References between collections
reference('<collection>') declares a frontmatter field that names an entry
of another collection by slug:
schema: Schema.Struct({
title: Schema.String,
author: reference('authors'),
})The build validates every reference — a dangling one (author: nobody) fails
the build naming the collection, entry, field and target. Resolve it with
getEntry('authors', entry.data.author).
RSS feeds from a collection
Declare feeds in app.config.ts; the build writes them next to
sitemap.xml, and voltro dev serves the same XML live:
export default {
// …
seo: { siteUrl: 'https://example.com' },
feeds: [{
path: '/rss.xml',
collection: 'posts',
title: 'My blog',
item: (e) => e.data.draft === 'true' ? null : ({
title: e.data.title, link: `/blog/${e.slug}`, date: e.data.date,
}),
}],
}Returning null from item excludes an entry — that is the draft filter:
keep a draft: true field in your schema and filter it in item and in your
page loaders (the changelog template's visibleReleases helper is the worked
example, including future-dated staging).
No MDX — islands carry the interactivity
Collection bodies are markdown, not MDX: JSX, imports and
{expressions} in a body are not executed. When a content page needs a live
widget, the surrounding PAGE provides it via the islands mechanism — the
content stays inert HTML and the widget hydrates alone:
// src/pages/blog/[slug]/page.tsx
export const interactive = 'islands' as const
export default function Post() {
const post = useLoaderData<ContentEntry<Post>>()
return (
<main>
<ReadingProgress /> {/* an island() component — the ONLY hydrated JS */}
<article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
</main>
)
}Limits + neighbors
- Images referenced from markdown bodies are copied as-is (no transform):
the image pipeline covers
?imageimports from code. Put content images underpublic/and reference them absolutely. - Files are DEVELOPER content — versioned with the code, deployed by the
build. Editorial content with drafts, roles and a save/publish pipeline is
@voltro/cms. Astro's remote "Content Layer loaders" map to@voltro/cmshere: remote/editorial sources go through the CMS, not through file collections.
Accessible heading permalinks
Rendered Markdown headings include a keyboard-focusable permalink named after the heading text. Only its decorative # is hidden from assistive technology; the link remains accessible. Inline formatting is reduced to plain text for the accessible name, and attribute characters are escaped.