vike-content-collection
v0.3.1
Published
Content collection plugin for Vike + Vite with zod schema validation
Maintainers
Readme
vike-content-collection
Type-safe, schema-validated content collections for Vike + Vite.
Define a Zod schema, drop in your markdown files, and get fully typed content with validated frontmatter -- at dev time and build time.
Documentation
| Guide | Description |
| ----- | ----------- |
| Getting Started | Installation, setup, and your first collection |
| Defining Collections | Schema formats, data collections, content directories |
| Querying Data | getCollection, getCollectionEntry, findCollectionEntries, usage patterns |
| Rendering Content | Markdown to HTML, headings, custom plugins |
| TypeScript Setup | Generated types, virtual module declarations, tsconfig |
| Advanced Features | Computed fields, references, drafts, sorting, and more |
| Internationalization | Multilingual content with slug suffix or metadata strategy |
Features
- Zod schema validation -- frontmatter is parsed and validated with precise error reporting (file, line, column)
- Full type inference -- auto-generated declaration file powers typesafe
getCollection(),getCollectionEntry(), andfindCollectionEntries() - Markdown, MDX & data collections --
.mdand.mdxfiles with frontmatter,.json/.yaml/.tomldata files, ortype: 'both'for mixed collections - Built-in rendering -- markdown and MDX to HTML via unified/remark/rehype, with heading extraction
- Pluggable renderers -- use the built-in markdown or MDX renderer, or implement your own
ContentRenderer - Computed fields -- derive reading time, excerpts, or any value from each entry
- Collection references -- cross-collection slug validation
- Draft mode -- drafts visible in dev, excluded in production
- Navigation helpers -- breadcrumbs, next/previous links, entry URLs, and collection entry tree for site navigation
- Content discovery -- related entries by shared metadata, cross-collection merge, unique values extraction
- Content series -- ordered multi-part content sequences with series-aware navigation
- i18n support -- locale detection and localized entry lookup via slug suffix or metadata
- Grouping & TOC -- group entries by any metadata key, build nested table-of-contents trees from headings
- Server-only by default -- runtime APIs automatically return safe no-op stubs on the client, keeping Node.js code out of the browser bundle
- HMR -- incremental updates on file changes during development
- Virtual module --
virtual:content-collectionexposes data to other Vite plugins
Quick Start
1. Install
npm install vike-content-collectionPeer dependencies (if not already installed):
npm install vike vite zod2. Add the Vite plugin
// vite.config.ts
import vikeContentCollection from 'vike-content-collection'
export default {
plugins: [vikeContentCollection()],
ssr: {
external: ['vike-content-collection']
}
}Marking the package as ssr.external ensures Vite doesn't bundle it during SSR, which is required for the plugin to work correctly. The plugin automatically provides no-op stubs for client-side bundles, so Node.js-specific code is never shipped to the browser.
3. Extend the Vike config
// +config.ts (root or pages-level)
import vikeContentCollectionConfig from 'vike-content-collection/config'
export default {
extends: [vikeContentCollectionConfig]
}4. Define a collection
Create a +Content.ts in any page directory:
// pages/blog/+Content.ts
import { z } from 'zod'
export const Content = z.object({
title: z.string(),
date: z.date(),
tags: z.array(z.string()).optional()
})5. Add content
Place .md or .mdx files alongside (or in subdirectories of) the +Content.ts:
---
title: "Getting Started"
date: 2025-03-10T00:00:00.000Z
tags:
- tutorial
---
Welcome to the blog.6. Query your collection
// pages/blog/+data.ts
import { getCollection } from 'vike-content-collection'
export function data() {
const posts = getCollection('blog')
return { posts }
}That's it. posts is fully typed based on your Zod schema.
Guide
Defining collections
The simplest form exports Content as a Zod schema:
export const Content = z.object({
title: z.string(),
date: z.date()
})For more control, export an object with a schema property:
export const Content = {
schema: z.object({
title: z.string(),
date: z.date(),
draft: z.boolean().default(false),
permalink: z.string().optional()
}),
computed: {
readingTime: ({ content }) => Math.ceil(content.split(/\s+/).length / 200),
},
slug: ({ metadata, defaultSlug }) => metadata.permalink ?? defaultSlug,
contentPath: 'articles', // fetch files from <contentRoot>/articles/ instead of the default
}Both named and default exports are supported:
export const Content = z.object({ ... }) // named (recommended)
export default { Content: z.object({ ... }) } // default with Content property
export default z.object({ ... }) // direct defaultIf the export has a
safeParsemethod it is treated as a plain Zod schema; otherwise the plugin expects aschemaproperty.
Collection names
The collection name is derived from the directory where +Content.ts lives:
| +Content.ts location | Collection name |
| -------------------------------- | --------------- |
| pages/blog/+Content.ts | "blog" |
| pages/docs/guides/+Content.ts | "docs/guides" |
Data collections
For structured data without a markdown body (author profiles, navigation config, etc.), set type: 'data'. The plugin scans for .json, .yaml/.yml, and .toml files instead of .md:
// pages/authors/+Content.ts
import { z } from 'zod'
export const Content = {
type: 'data',
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string().url()
})
}Each file becomes one entry. The content field is an empty string for data entries.
Mixed collections
Set type: 'both' to include both content (.md/.mdx) and data (.json/.yaml/.toml) files in a single collection. The plugin selects the correct parser per-file based on extension:
export const Content = {
type: 'both',
schema: z.object({
title: z.string(),
description: z.string().optional(),
})
}Content directory
By default, content files live alongside their +Content.ts. Set contentRoot to keep them separate:
vikeContentCollection({ contentRoot: 'content' })With this config, a collection defined at pages/blog/+Content.ts loads files from content/blog/.
You can also override the content folder on a per-collection basis using contentPath in the extended config:
// pages/blog/+Content.ts
export const Content = {
schema: z.object({ title: z.string() }),
contentPath: 'articles', // loads from content/articles/ instead of content/blog/
}Querying collections
getCollection(name)
Returns all entries in a collection, fully typed:
import { getCollection } from 'vike-content-collection'
const posts = getCollection('blog')getCollectionEntry(name, slug)
Looks up a single entry by slug. Returns the entry or undefined:
import { getCollectionEntry } from 'vike-content-collection'
const post = getCollectionEntry('blog', 'getting-started')findCollectionEntries(name, filter)
Finds entries matching a filter. Always returns an array:
| Filter type | Example | Returns |
| ----------- | ------- | ------- |
| RegExp | /^tutorial-/ | Array of matching entries |
| Predicate | (e) => !e._isDraft | Array of matching entries |
| Array | ['intro', /^guide-/] | Array matching any filter (OR) |
import { findCollectionEntries } from 'vike-content-collection'
// Pattern match
const tutorials = findCollectionEntries('blog', /^tutorial-/)
// Predicate
const published = findCollectionEntries('blog', (e) => !e._isDraft)
// Combined filters (OR semantics)
const selected = findCollectionEntries('blog', [
'intro',
/^tutorial-/,
(entry) => entry.metadata.featured === true,
])Entry shape
Every entry returned by getCollection, getCollectionEntry, or findCollectionEntries has:
| Field | Type | Description |
| -------------- | ----------------------- | ------------------------------------------------------ |
| filePath | string | Absolute path to the source file |
| slug | string | Identifier derived from filename (or custom function) |
| metadata | Inferred from schema | Validated frontmatter data |
| content | string | Raw markdown body (empty string for data entries) |
| computed | Record<string, unknown> | Values from computed field functions |
| lastModified | Date \| undefined | Git-based last modification date (opt-in) |
| _isDraft | boolean | Whether the entry is a draft |
| index | Record<string, Entry> | Lookup map of all entries in the same collection |
Rendering markdown
Convert an entry's markdown to HTML with renderEntry():
import { getCollectionEntry, renderEntry } from 'vike-content-collection'
const post = getCollectionEntry('blog', 'getting-started')
if (post) {
const { html, headings } = await renderEntry(post)
}headings is an array of { depth, text, id } extracted during rendering. Heading elements in the HTML include matching id attributes via rehype-slug.
Custom plugins
Pass remark or rehype plugins to extend rendering:
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
const { html } = await renderEntry(post, {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeHighlight],
})MDX rendering
Use createMdxRenderer() to render .mdx files that contain JSX syntax:
import { createMdxRenderer, renderEntry } from 'vike-content-collection'
const mdxRenderer = createMdxRenderer()
const { html, headings } = await renderEntry(post, { renderer: mdxRenderer })Custom renderers
Implement the ContentRenderer interface to provide your own rendering pipeline:
import type { ContentRenderer } from 'vike-content-collection'
const myRenderer: ContentRenderer = {
async render(content, options) {
// Your custom rendering logic
return { html: '<p>rendered</p>', headings: [] }
}
}
const { html } = await renderEntry(post, { renderer: myRenderer })Extracting headings only
Use extractHeadings() when you only need a table of contents (faster than a full render):
import { extractHeadings } from 'vike-content-collection'
const headings = await extractHeadings(post.content)
// [{ depth: 1, text: 'Title', id: 'title' }, ...]Computed fields
Derive additional data from each entry. Computed functions run after validation and receive { metadata, content, filePath, slug }:
export const Content = {
schema: z.object({ title: z.string() }),
computed: {
readingTime: ({ content }) => Math.ceil(content.split(/\s+/).length / 200),
wordCount: ({ content }) => content.split(/\s+/).length,
excerpt: ({ content }) => content.slice(0, 160).trim() + '...',
}
}Access computed values on entries:
const posts = getCollection('blog')
posts[0].computed.readingTime // number
posts[0].computed.excerpt // stringCollection references
Use reference() to validate that a metadata field points to an existing slug in another collection. The argument is typed — it autocompletes to known collection names when generated types are present:
import { z } from 'zod'
import { reference } from 'vike-content-collection'
export const Content = z.object({
title: z.string(),
author: reference('authors'),
})After all collections are loaded, the plugin runs a cross-collection validation pass and warns about broken references.
Custom slugs
By default, slugs come from the filename (minus extension). Override with a slug function. Use defineCollection() for typed metadata:
import { defineCollection } from 'vike-content-collection'
export const Content = defineCollection({
schema: z.object({
title: z.string(),
permalink: z.string().optional()
}),
slug: ({ metadata, filePath, defaultSlug }) =>
metadata.permalink ?? defaultSlug, // ← fully typed
})Draft mode
Entries with a truthy draft metadata field are automatically excluded in production builds. During development they remain visible with _isDraft: true.
Configure the draft field name or override filtering:
vikeContentCollection({
drafts: {
field: 'draft', // metadata field to check (default: "draft")
includeDrafts: false, // force exclude even in dev
}
})Sorting & pagination
sortCollection(entries, key, order?)
Sort entries by a metadata key. Returns a new array:
import { getCollection, sortCollection } from 'vike-content-collection'
const posts = getCollection('blog')
const byDate = sortCollection(posts, 'date', 'desc') // newest first
const byTitle = sortCollection(posts, 'title', 'asc') // alphabeticalSupports dates, numbers, and strings. Defaults to 'asc'.
paginate(entries, options)
Split entries into pages:
import { paginate } from 'vike-content-collection'
const page = paginate(posts, { pageSize: 10, currentPage: 2 })
page.items // entries for this page
page.currentPage // 2
page.totalPages // total number of pages
page.totalItems // total entry count
page.hasNextPage // boolean
page.hasPreviousPage // booleangroupBy(entries, key)
Group entries by a metadata key. Array values (e.g. tags) place the entry in multiple groups:
import { getCollection, groupBy } from 'vike-content-collection'
const posts = getCollection('blog')
const byTag = groupBy(posts, 'tags')
// Map { 'javascript' => [...], 'react' => [...] }Navigation
getBreadcrumbs(collectionName, slug?, options?)
Generate breadcrumb trails from collection names and entry slugs:
import { getBreadcrumbs } from 'vike-content-collection'
const crumbs = getBreadcrumbs('docs/guides', 'getting-started', {
labels: { docs: 'Documentation' },
})
// [
// { label: 'Documentation', path: '/docs' },
// { label: 'Guides', path: '/docs/guides' },
// { label: 'Getting Started', path: '/docs/guides/getting-started' },
// ]getAdjacentEntries(name, currentSlug, options?)
Find previous/next entries for navigation links:
import { getAdjacentEntries } from 'vike-content-collection'
const { prev, next } = getAdjacentEntries('blog', 'my-post', {
sortBy: 'date',
order: 'desc',
})getCollectionTree(name)
Get collection entries as a hierarchical tree based on slug paths (for sidebars):
import { getCollectionTree } from 'vike-content-collection'
const tree = getCollectionTree('docs')
// [{ name: 'intro', fullName: 'intro', children: [] }, { name: 'guides', fullName: '', children: [...] }]buildTocTree(headings)
Convert flat headings into a nested table-of-contents tree:
import { extractHeadings, buildTocTree } from 'vike-content-collection'
const headings = await extractHeadings(post.content)
const toc = buildTocTree(headings)
// [{ depth: 2, text: 'Setup', id: 'setup', children: [...] }]getRelatedEntries(name, slug, options)
Find entries related by shared metadata (tags, category, etc.):
import { getRelatedEntries } from 'vike-content-collection'
const related = getRelatedEntries('blog', 'my-post', {
by: ['tags', 'category'],
limit: 3,
})mergeCollections(names)
Combine entries from multiple collections:
import { mergeCollections, sortCollection } from 'vike-content-collection'
const all = mergeCollections(['blog', 'news'])
const latest = sortCollection(all, 'date', 'desc')uniqueValues(entries, key)
Get all unique values for a metadata key:
import { getCollection, uniqueValues } from 'vike-content-collection'
const allTags = uniqueValues(getCollection('blog'), 'tags')
// ['javascript', 'python', 'react']getEntryUrl(collectionName, slug, options?)
Generate a URL path for an entry:
import { getEntryUrl } from 'vike-content-collection'
const url = getEntryUrl('docs/guides', 'intro', { basePath: '/en' })
// '/en/docs/guides/intro'Content series
getSeries(name, currentSlug, seriesName, options?)
Get an ordered series of entries with navigation:
import { getSeries } from 'vike-content-collection'
const series = getSeries('blog', 'part-2', 'react-tutorial')
// { name: 'react-tutorial', entries: [...], currentIndex: 1, total: 3, prev, next }i18n locales
getAvailableLocales(name, baseSlug, options?)
Get available locales for a base slug:
import { getAvailableLocales } from 'vike-content-collection'
const locales = getAvailableLocales('docs', 'getting-started')
// ['', 'de', 'fr']getLocalizedEntry(name, baseSlug, locale, options?)
Get a specific localized version:
import { getLocalizedEntry } from 'vike-content-collection'
const frEntry = getLocalizedEntry('docs', 'getting-started', 'fr')Git last modified
Populate lastModified on each entry from git log:
vikeContentCollection({ lastModified: true })const post = getCollectionEntry('blog', 'intro')
post?.lastModified // Date | undefinedReturns undefined if git is unavailable or the file is untracked.
Type generation
The plugin generates .vike-content-collection/types.d.ts automatically on build, dev server start, and HMR. Add it to your tsconfig.json:
{
"include": [
"src",
".vike-content-collection/**/*"
]
}This powers full type inference for getCollection(), getCollectionEntry(), and findCollectionEntries() -- no manual type annotations needed.
Virtual module
Other Vite plugins or app code can import collection data directly:
import { collections } from 'virtual:content-collection'collections is a record keyed by collection directory path. Each value contains type ("content", "data", or "both") and an entries array.
Plugin Options
vikeContentCollection({
contentDir: 'pages',
contentRoot: 'content',
declarationOutDir: '.vike-content-collection',
declarationFileName: 'types.d.ts',
drafts: {
field: 'draft',
includeDrafts: true,
},
lastModified: true,
})| Option | Type | Default | Description |
| ---------------------- | --------- | ---------------------------------- | ------------------------------------------------- |
| contentDir | string | "pages" | Directory to scan for +Content.ts config files |
| contentRoot | string | same as contentDir | Directory where content/data files live |
| declarationOutDir | string | ".vike-content-collection" | Output directory for the generated declaration file |
| declarationFileName | string | "types.d.ts" | Filename for the generated declaration file |
| drafts.field | string | "draft" | Metadata field name for draft status |
| drafts.includeDrafts | boolean | true in dev, false in prod | Force include or exclude draft entries |
| lastModified | boolean | false | Populate lastModified from git history |
Schema Validation Errors
When metadata fails validation, the build halts with a detailed error:
ContentCollectionValidationError: [vike-content-collection] Schema validation failed:
pages/blog/post.md:4 (at "metadata.name"): Expected string, received numberErrors include the file path, line number, Zod error path, and validation message. This works identically in vite build and vite dev (surfaced via HMR).
How It Works
- Scan -- finds
+Content.tsfiles incontentDironbuildStart - Parse -- extracts YAML frontmatter from
.md/.mdxfiles (via gray-matter), or reads.json/.yaml/.tomlfor data collections - Validate -- checks each entry against its Zod schema, mapping errors back to source line numbers
- Compute -- runs computed field functions on validated entries
- Filter -- excludes draft entries in production
- Store -- holds entries in memory, keyed by collection name
- References -- verifies cross-collection
reference()slugs exist - Types -- emits
.vike-content-collection/types.d.ts - Serve -- exposes data through
virtual:content-collection - Client noop -- intercepts client-side imports and replaces them with safe no-op stubs
- HMR -- incrementally re-processes changed files, regenerates types, and invalidates the virtual module
API Reference
Functions
import {
vikeContentCollectionPlugin, // Vite plugin factory (also the default export)
getCollection, // all entries of a collection
getCollectionEntry, // single entry by slug
findCollectionEntries, // filtered entries (RegExp, predicate, or array)
renderEntry, // content -> HTML (uses default or custom renderer)
extractHeadings, // headings from markdown
buildTocTree, // nested TOC tree from flat headings
createMarkdownRenderer, // built-in markdown renderer factory
createMdxRenderer, // built-in MDX renderer factory
sortCollection, // sort by metadata key
paginate, // paginate entries
groupBy, // group entries by metadata key
getBreadcrumbs, // breadcrumb trail from collection path
getAdjacentEntries, // previous/next entries in a collection
getCollectionTree, // entry hierarchy as a tree from slug paths
getEntryUrl, // URL path for a collection entry
getRelatedEntries, // related entries by shared metadata
mergeCollections, // combine entries from multiple collections
uniqueValues, // unique values for a metadata key
getSeries, // ordered content series with navigation
getAvailableLocales, // available locales for an entry
getLocalizedEntry, // localized version of an entry
reference, // cross-collection reference schema
defineCollection, // type-safe collection definition helper
} from 'vike-content-collection'Types
import type {
ContentCollectionPluginOptions,
ContentCollectionConfig,
ContentCollectionDefinition,
ResolvedContentConfig,
ComputedFieldInput,
SlugInput,
CollectionMap,
CollectionName,
TypedCollectionEntry,
CollectionEntryFilter,
CollectionEntryFilterInput,
CollectionEntryPredicate,
ParsedMarkdown,
MetadataLineMap,
ValidationIssue,
ContentRenderer,
RenderResult,
RenderOptions,
Heading,
TocNode,
PaginationResult,
Breadcrumb,
BreadcrumbOptions,
AdjacentEntries,
CollectionTreeNode,
EntryNode,
FolderNode,
TypedTreeNode,
TypedEntryNode,
TypedFolderNode,
EntryUrlOptions,
RelatedEntriesOptions,
SeriesResult,
SeriesOptions,
LocaleOptions,
} from 'vike-content-collection'| Type | Description |
| ---- | ----------- |
| ContentCollectionPluginOptions | Options for vikeContentCollection() |
| ContentCollectionConfig | Shape of the +Content.ts export |
| ContentCollectionDefinition | Extended config with schema, computed, slug, type, contentPath |
| ResolvedContentConfig | Normalized config after resolving schema or definition |
| ComputedFieldInput | Input to computed field functions |
| SlugInput | Input to custom slug functions |
| CollectionName | Union of known collection names (falls back to string before type generation) |
| CollectionMap | Augmentable interface mapping collection names to types |
| TypedCollectionEntry<T, C> | A single collection entry with typed metadata and computed fields |
| CollectionEntryFilter<T> | Single filter: string, RegExp, or predicate |
| CollectionEntryFilterInput<T> | One or more filters for findCollectionEntries() |
| CollectionEntryPredicate<T> | Predicate function for filtering entries |
| ParsedMarkdown | Result of parsing a markdown file |
| MetadataLineMap | Maps metadata key paths to line numbers |
| ValidationIssue | Validation error with file, line, path, and message |
| ContentRenderer | Interface for pluggable content renderers |
| RenderResult | { html: string, headings: Heading[] } |
| RenderOptions | Custom remarkPlugins, rehypePlugins, and optional renderer |
| Heading | { depth: number, text: string, id: string } |
| PaginationResult<T> | Paginated result with items, page info, and navigation |
| Breadcrumb | { label: string, path: string } |
| BreadcrumbOptions | Options for getBreadcrumbs(): labels, basePath, includeCurrent, currentLabel |
| AdjacentEntries<T> | { prev: TypedCollectionEntry \| undefined, next: TypedCollectionEntry \| undefined } |
| TocNode | { depth, text, id, children: TocNode[] } |
| TypedTreeNode<T> | TypedEntryNode<T> \| TypedFolderNode<T> — returned by getCollectionTree() |
| TypedEntryNode<T> | { name, fullName, entry: TypedCollectionEntry<T> } |
| TypedFolderNode<T> | { name, fullName, children: TypedTreeNode<T>[] } |
| CollectionTreeNode | EntryNode \| FolderNode — internal (untyped) tree nodes |
| EntryUrlOptions | Options for getEntryUrl(): basePath, extension |
| RelatedEntriesOptions | Options for getRelatedEntries(): by, limit |
| SeriesResult<T> | Result of getSeries(): name, entries, currentIndex, total, prev, next |
| SeriesOptions | Options for getSeries(): seriesField, orderField |
| LocaleOptions | Options for i18n helpers: strategy, field, separator |
Development
bun install # Install dependencies
bun run build # Compile TypeScript to dist/
bun test # Run all unit tests
bun run bench # Run benchmarks and compare against baseline
bun run bench:save # Run benchmarks and save as new baseline
bun run lint # Check linting (Biome)Benchmarks
The benchmarks/ directory contains performance benchmarks for key functions (parsing, validation, sorting, etc.). Use bun run bench:save to establish a baseline, then bun run bench after making changes to detect regressions. The runner exits with code 1 if any benchmark regresses beyond the threshold (default ±10%).
bun run bench # Compare against saved baseline
bun run bench:save # Save current results as baseline
bun run bench -- --threshold 15 # Custom regression threshold (%)Requirements
- Node.js >= 18
- Vite >= 7.0.0
- Vike >= 0.4.250
- Zod >= 3.0.0
License
MIT
