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 ?image imports from code. Put content images under public/ 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/cms here: remote/editorial sources go through the CMS, not through file collections.

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.