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.
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.