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.mdhello; nested folders stay in the slug (database/joins.mddatabase/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.