# 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. ## 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(), and findCollectionEntries() - Markdown, MDX & data collections -- .md and .mdx files with frontmatter, or .json / .yaml / .toml data files - 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-collection exposes data to other Vite plugins ## Development - `bun run bench` -- run benchmarks and compare against saved baseline (exits 1 on regression) - `bun run bench:save` -- run benchmarks and save results as the new baseline - Benchmarks cover parsing, validation, sorting, grouping, rendering, and navigation functions - Regression threshold defaults to ±10%, configurable via `--threshold` ## Requirements - Node.js >= 18 - Vite >= 7.0.0 - Vike >= 0.4.250 - Zod >= 3.0.0 --- # Getting Started This guide walks you through setting up vike-content-collection in a new or existing Vike project. ## Prerequisites - Node.js >= 18 - An existing Vike + Vite project (or a new one) ## Installation Install the plugin: ```bash npm install vike-content-collection ``` It has three peer dependencies. Install any you don't already have: ```bash npm install vike vite zod ``` ## Project setup There are three things to configure: the Vite plugin, the Vike config, and your TypeScript config. ### 1. Register the Vite plugin Add the plugin to your vite.config.ts: ```ts // vite.config.ts import vikeContentCollection from 'vike-content-collection' import vike from 'vike/plugin' export default { plugins: [ vike(), vikeContentCollection() ], ssr: { external: ['vike-content-collection'] } } ``` The ssr.external entry is required -- it tells Vite not to bundle the package during SSR so the plugin's runtime works correctly. The plugin automatically provides no-op stubs for client-side bundles, so Node.js-specific code is never shipped to the browser. No extra configuration is needed for this. The plugin accepts options but none are required to get started. ### 2. Extend the Vike config In your root +config.ts, extend with the content collection config. This registers +Content.ts as a recognized Vike setting: ```ts // +config.ts import vikeContentCollectionConfig from 'vike-content-collection/config' export default { extends: [vikeContentCollectionConfig] } ``` ### 3. Update tsconfig.json The plugin generates a declaration file that powers type inference. Add the generated directory to your TypeScript includes: ```json { "include": [ "src", ".vike-content-collection/**/*" ] } ``` The file .vike-content-collection/types.d.ts is regenerated automatically when the dev server starts, on HMR updates, and during builds. You should add .vike-content-collection/ to your .gitignore. ## Your first collection ### 1. Define the schema Create a +Content.ts file in a page directory. Export a Zod schema that describes the shape of your content's frontmatter: ```ts // 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() }) ``` ### 2. Add a markdown file Create a markdown file in the same directory. The YAML frontmatter must match the schema: ```md --- title: "Hello World" date: 2025-03-10T00:00:00.000Z tags: - introduction --- This is my first blog post using vike-content-collection. ``` The slug for this entry will be "hello-world" (derived from the filename). ### 3. Query the collection Use getCollection() in a +data.ts file to load your content and pass it to the page: ```ts // pages/blog/+data.ts import { getCollection } from 'vike-content-collection' export function data() { const posts = getCollection('blog') return { posts } } ``` The collection name "blog" matches the directory where +Content.ts lives relative to your content root (defaults to pages/). ### 4. Render on the page Access the data in your page component via Vike's useData(): ```tsx // pages/blog/+Page.tsx import { useData } from 'vike-renderer/useData' export function Page() { const { posts } = useData() return ( ) } ``` ## Verification Start the dev server: ```bash npm run dev ``` If your schema and markdown are valid, the page renders with your content. If the frontmatter doesn't match the schema, you'll see a detailed validation error pointing to the exact file and line: ``` ContentCollectionValidationError: [vike-content-collection] Schema validation failed: pages/blog/hello-world.md:3 (at "title"): Expected string, received number ``` ## Project structure A typical project looks like this: ``` my-project/ ├── pages/ │ ├── +config.ts # Vike config with extends │ ├── blog/ │ │ ├── +Content.ts # Zod schema for blog posts │ │ ├── +data.ts # Load posts with getCollection() │ │ ├── +Page.tsx # Render posts │ │ ├── hello-world.md # Blog post │ │ └── second-post.md # Another blog post │ └── authors/ │ ├── +Content.ts # Zod schema for author data │ └── jane.yaml # Author data file ├── vite.config.ts # Vite + plugin registration ├── tsconfig.json # includes .vike-content-collection/**/* └── .vike-content-collection/ # auto-generated types (gitignored) └── types.d.ts ``` --- # Defining Collections A collection is defined by a +Content.ts file that exports a Zod schema. The plugin discovers these files, validates your content against the schema, and makes the data available at runtime. ## Schema formats ### Simple schema The most common format. Export Content as a Zod schema directly: ```ts // 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() }) ``` The plugin detects this format by checking for a safeParse method on the export. ### Extended config For features like computed fields, custom slugs, or data collections, export an object with a schema property: ```ts // pages/blog/+Content.ts import { z } from 'zod' import { defineCollection } from 'vike-content-collection' export const Content = defineCollection({ 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 /articles/ instead of /blog/ }) ``` Wrapping with defineCollection() gives full type inference -- metadata inside slug and computed callbacks is typed according to the Zod schema. You can also write the config as a plain object without defineCollection(), but you won't get typed metadata in the callbacks. The extended config supports these fields: - schema (ZodSchema) -- Required. Zod schema for validating frontmatter. - type ('content' | 'data' | 'both') -- Collection type (default: 'content'). - computed (Record) -- Functions that derive extra data per entry. - slug (Function) -- Custom slug generation. - contentPath (string) -- Override the folder inside the content root to fetch files from. ### Export styles All of these are equivalent: ```ts // Named export (recommended) export const Content = z.object({ ... }) // Default export wrapping Content export default { Content: z.object({ ... }) } // Direct default export export default z.object({ ... }) ``` If the export has a safeParse method it is treated as a plain Zod schema; otherwise the plugin expects a schema property. ## Collection naming The collection name is automatically derived from the directory path of +Content.ts relative to the content root: - pages/blog/+Content.ts → "blog" - pages/docs/guides/+Content.ts → "docs/guides" - pages/+Content.ts → "." This name is what you pass to getCollection(), getCollectionEntry(), and findCollectionEntries(). ## Content collections (markdown and MDX) The default collection type. The plugin scans for .md and .mdx files and parses YAML frontmatter: ```md --- title: "My Post" date: 2025-06-15T00:00:00.000Z --- The markdown body becomes the `content` field on each entry. ``` The frontmatter is validated against your schema. The body text is available as entry.content. ### File discovery By default, the plugin looks for .md and .mdx files in the same directory as the +Content.ts file and its subdirectories. Files are matched recursively. MDX files work identically to markdown files for frontmatter parsing -- use createMdxRenderer() for rendering MDX content. ## Data collections For structured data without a markdown body -- author profiles, navigation config, product catalogs -- set type: 'data': ```ts // 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(), social: z.object({ twitter: z.string().optional(), github: z.string().optional() }).optional() }) } ``` The plugin scans for .json, .yaml/.yml, and .toml files. Each file is one entry, and its entire content is validated against the schema. The content field is an empty string for data entries. Example data file: ```yaml # pages/authors/jane-doe.yaml name: Jane Doe bio: Writer and developer avatar: https://example.com/jane.jpg social: github: janedoe ``` This entry has the slug "jane-doe" and is accessible via getCollection('authors'). ## Mixed collections (both) When a single collection contains both content files (.md/.mdx) and data files (.json/.yaml/.toml), set type: 'both'. The plugin scans for all supported file extensions and selects the correct parser per-file based on its extension: - .md / .mdx files are parsed as content (frontmatter becomes metadata, body becomes content) - .json / .yaml / .yml / .toml files are parsed as data (entire file becomes metadata, content is an empty string) ```ts // pages/projects/+Content.ts import { z } from 'zod' export const Content = { type: 'both', schema: z.object({ title: z.string(), description: z.string().optional(), }) } ``` Your Zod schema must accommodate both file types. You can use optional fields, z.union(), or a discriminated union depending on your needs. ## Content directory configuration ### Default behavior Content files live alongside their +Content.ts: ``` pages/blog/ ├── +Content.ts ├── hello-world.md └── another-post.md ``` ### Separate content root Set contentRoot to keep content files in a different directory: ```ts // vite.config.ts vikeContentCollection({ contentRoot: 'content' }) ``` With this config, a collection defined at pages/blog/+Content.ts loads its files from content/blog/ instead: ``` pages/blog/ └── +Content.ts # schema definition content/blog/ ├── hello-world.md # content lives here └── another-post.md ``` ### Config scan directory By default, the plugin scans pages/ for +Content.ts files. Change this with contentDir: ```ts vikeContentCollection({ contentDir: 'src/pages' }) ``` ### Per-collection content path Override the content folder for a specific collection using contentPath in the extended config: ```ts // pages/blog/+Content.ts export const Content = { schema: z.object({ title: z.string() }), contentPath: 'articles', } ``` With contentRoot: 'content', this collection fetches files from content/articles/ instead of content/blog/. Without a contentRoot, files are loaded from pages/articles/ instead. ## Schema validation errors When a file's frontmatter doesn't match the schema, the plugin halts with a precise error: ``` ContentCollectionValidationError: [vike-content-collection] Schema validation failed: pages/blog/hello-world.md:4 (at "metadata.name"): Expected string, received number ``` The error includes: - File path -- which file failed - Line number -- where in the frontmatter the issue is (for markdown files) - Zod error path -- which field failed (e.g. metadata.name) - Message -- the Zod validation message This works in both vite dev (surfaced via HMR) and vite build (stops the build). --- # Querying Data Once you've defined collections and added content, use getCollection(), getCollectionEntry(), and findCollectionEntries() to access your data. All functions are fully typed when the generated declaration file is included in your tsconfig.json. ## getCollection(name) Returns all entries in a collection as a typed array: ```ts import { getCollection } from 'vike-content-collection' const posts = getCollection('blog') // TypedCollectionEntry<{ title: string; date: Date; tags?: string[] }>[] ``` If the collection doesn't exist, an error is thrown listing the available collection names. ### Typical usage in a +data.ts file ```ts // pages/blog/+data.ts import { getCollection } from 'vike-content-collection' export function data() { const posts = getCollection('blog') return { posts } } ``` The returned data is available in your page component via Vike's useData(). ## getCollectionEntry(name, slug) Looks up a single entry by slug. Returns the entry or undefined: ```ts import { getCollectionEntry } from 'vike-content-collection' const post = getCollectionEntry('blog', 'getting-started') if (post) { console.log(post.metadata.title) } ``` ## findCollectionEntries(name, filter) Finds entries matching a filter. Always returns an array: ### By pattern (RegExp) Pass a regular expression to match slugs: ```ts import { findCollectionEntries } from 'vike-content-collection' const tutorials = findCollectionEntries('blog', /^tutorial-/) ``` ### By predicate (function) Pass a function to filter entries: ```ts const published = findCollectionEntries('blog', (entry) => !entry._isDraft) const recent = findCollectionEntries('blog', (entry) => entry.metadata.date > new Date('2025-01-01') ) ``` ### By array (combined filters) Pass an array of filters (string, RegExp, or predicate). Returns entries matching any filter (OR semantics): ```ts const selected = findCollectionEntries('blog', [ 'intro', /^tutorial-/, (entry) => entry.metadata.featured === true, ]) ``` ### Filter summary - getCollectionEntry: string (e.g. 'getting-started') → Single entry or undefined - findCollectionEntries: RegExp (e.g. /^tutorial-/) → Array of matching entries - findCollectionEntries: Predicate (e.g. (e) => !e._isDraft) → Array of matching entries - findCollectionEntries: Array (e.g. ['intro', /^guide-/]) → Array matching any filter (OR) ## Entry shape Every entry returned by getCollection, getCollectionEntry, or findCollectionEntries has these fields: - 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) -- 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) -- Lookup map of all entries in the same collection ### The index field Each entry carries an index -- a record of all entries in the same collection, keyed by slug. This lets you navigate between entries without a separate getCollection call: ```ts const post = getCollectionEntry('blog', 'part-2') if (post) { const part1 = post.index['part-1'] console.log(part1?.metadata.title) } ``` ## Usage patterns ### Blog listing with sorted posts ```ts // pages/blog/+data.ts import { getCollection, sortCollection } from 'vike-content-collection' export function data() { const posts = getCollection('blog') const sorted = sortCollection(posts, 'date', 'desc') return { posts: sorted } } ``` ### Single post page ```ts // pages/blog/@slug/+data.ts import { getCollectionEntry, renderEntry } from 'vike-content-collection' import type { PageContext } from 'vike/types' export async function data(pageContext: PageContext) { const post = getCollectionEntry('blog', pageContext.routeParams.slug) if (!post) throw new Error('Post not found') const { html, headings } = await renderEntry(post) return { post, html, headings } } ``` ### Combining collections ```ts // pages/blog/@slug/+data.ts import { getCollectionEntry } from 'vike-content-collection' export function data(pageContext: PageContext) { const post = getCollectionEntry('blog', pageContext.routeParams.slug) if (!post) throw new Error('Post not found') const author = getCollectionEntry('authors', post.metadata.author) return { post, author } } ``` ### Paginated listing ```ts // pages/blog/+data.ts import { getCollection, sortCollection, paginate } from 'vike-content-collection' export function data(pageContext: PageContext) { const posts = getCollection('blog') const sorted = sortCollection(posts, 'date', 'desc') const page = paginate(sorted, { pageSize: 10, currentPage: Number(pageContext.routeParams.page) || 1 }) return { page } } ``` ## Type safety The plugin auto-generates .vike-content-collection/types.d.ts which augments the CollectionMap interface. This means: - getCollection('blog') returns entries typed with the exact schema from pages/blog/+Content.ts - getCollectionEntry('blog', 'slug') returns a properly typed entry - findCollectionEntries('blog', /pattern/) returns properly typed entries - Autocomplete works for collection names and metadata fields No manual type annotations are needed. The types update automatically on dev server start, HMR, and builds. --- # Rendering Content The plugin includes a pluggable rendering system with built-in renderers for markdown and MDX, powered by unified, remark, and rehype. Use it to convert content entries to HTML and extract headings for navigation. You can also implement your own ContentRenderer for custom rendering pipelines. ## renderEntry(entry, options?) Converts a collection entry's markdown content to HTML: ```ts import { getCollectionEntry, renderEntry } from 'vike-content-collection' const post = getCollectionEntry('blog', 'getting-started') if (post) { const { html, headings } = await renderEntry(post) } ``` ### Return value renderEntry returns a RenderResult: - html (string) -- The rendered HTML string - headings (Heading[]) -- Headings extracted during rendering Each heading has: - depth (number) -- Heading level (1-6) - text (string) -- Text content of the heading - id (string) -- Generated slug for anchor links Heading elements in the rendered HTML include matching id attributes via rehype-slug, so #installation links work out of the box. ## Full example: single post page ```ts // pages/blog/@slug/+data.ts import { getCollectionEntry, renderEntry } from 'vike-content-collection' import type { PageContext } from 'vike/types' export async function data(pageContext: PageContext) { const post = getCollectionEntry('blog', pageContext.routeParams.slug) if (!post) throw new Error('Post not found') const { html, headings } = await renderEntry(post) return { post, html, headings } } ``` ```tsx // pages/blog/@slug/+Page.tsx import { useData } from 'vike-renderer/useData' export function Page() { const { post, html, headings } = useData() return (

{post.metadata.title}

) } ``` ## Custom remark and rehype plugins Extend the rendering pipeline by passing custom plugins: ```ts import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' const { html, headings } = await renderEntry(post, { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeHighlight], }) ``` ### RenderOptions - remarkPlugins (any[]) -- Additional remark plugins to apply - rehypePlugins (any[]) -- Additional rehype plugins to apply - renderer (ContentRenderer) -- Custom renderer (defaults to built-in markdown renderer) Custom plugins are added after the built-in ones. The built-in pipeline is: 1. remark-parse -- parse markdown to AST 2. Heading extraction (built-in) 3. Your remarkPlugins 4. remark-rehype -- convert to HTML AST 5. rehype-slug -- add id attributes to headings 6. Your rehypePlugins 7. rehype-stringify -- serialize to HTML string ### Common plugin combinations GitHub Flavored Markdown (tables, strikethrough, task lists): ```ts import remarkGfm from 'remark-gfm' const { html } = await renderEntry(post, { remarkPlugins: [remarkGfm], }) ``` Syntax highlighting: ```ts import rehypeHighlight from 'rehype-highlight' const { html } = await renderEntry(post, { rehypePlugins: [rehypeHighlight], }) ``` Math rendering: ```ts import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' const { html } = await renderEntry(post, { remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex], }) ``` ## extractHeadings(content) Extracts headings from raw markdown without a full HTML render. Use this when you only need a table of contents: ```ts import { extractHeadings } from 'vike-content-collection' const headings = await extractHeadings(post.content) // [{ depth: 1, text: 'Title', id: 'title' }, { depth: 2, text: 'Section', id: 'section' }] ``` This parses the markdown AST just enough to find heading nodes, making it faster than renderEntry when you don't need the HTML output. ## MDX rendering For .mdx files that contain JSX syntax, use createMdxRenderer(): ```ts import { createMdxRenderer, renderEntry } from 'vike-content-collection' const mdxRenderer = createMdxRenderer() const { html, headings } = await renderEntry(post, { renderer: mdxRenderer }) ``` The MDX renderer uses remark-mdx to parse MDX syntax. JSX elements are serialized as their HTML tag equivalents. For full JSX component evaluation, implement a custom ContentRenderer. ## Custom renderers Implement the ContentRenderer interface for custom rendering: ```ts const myRenderer: ContentRenderer = { async render(content, options) { return { html: myRender(content), headings: [] } } } const { html } = await renderEntry(post, { renderer: myRenderer }) ``` Built-in renderer factories accept default plugins: ```ts const mdRenderer = createMarkdownRenderer({ remarkPlugins: [remarkGfm] }) const mdxRenderer = createMdxRenderer({ remarkPlugins: [remarkGfm] }) ``` ### When to use each - .md files, default → renderEntry(entry) - .mdx files → renderEntry(entry, { renderer: createMdxRenderer() }) - Custom format → renderEntry(entry, { renderer: myRenderer }) - Need only headings (table of contents) → extractHeadings() --- # TypeScript Setup This guide explains how to configure TypeScript in your project so that vike-content-collection provides full type inference for your collections, including autocomplete for collection names and typed metadata. ## Overview The plugin uses two mechanisms to deliver type safety: 1. Auto-generated declaration file -- .vike-content-collection/types.d.ts (or a custom directory via the declarationOutDir plugin option) is emitted automatically and augments the CollectionMap interface via declaration merging. This powers typed getCollection(), getCollectionEntry(), and findCollectionEntries() calls. 2. Virtual module declaration -- The virtual:content-collection Vite virtual module needs a manual type declaration if you import it directly. ## 1. Include the generated types The plugin generates a .vike-content-collection/ directory at your project root containing a types.d.ts file. You need to tell TypeScript about it by adding it to your tsconfig.json: ```json { "include": [ "src", ".vike-content-collection/**/*" ] } ``` If your tsconfig.json already has an include array, just append ".vike-content-collection/**/*" to it. Make sure your existing entries (like "src") remain. ### When is it generated? The declaration file is regenerated automatically: - On dev server start (vite dev) - On HMR updates when content files or +Content.ts configs change - During production builds (vite build) You don't need to run any manual command -- just start the dev server and the file appears. ### Add it to .gitignore The generated directory should not be committed to version control. Add it to your .gitignore: ``` .vike-content-collection/ ``` ## 2. How the generated types work The plugin uses TypeScript module augmentation to populate the CollectionMap interface exported by vike-content-collection. Here's what the generated file looks like for a project with a blog collection: ```ts // Auto-generated by vike-content-collection — do not edit import type { z } from 'zod' import type * as _blog from '../pages/blog/+Content' type _ResolveSchema = C extends { schema: infer S } ? S : C type _ResolveContent = M extends { Content: infer C } ? _ResolveSchema : M extends { default: { Content: infer C } } ? _ResolveSchema : M extends { default: infer D } ? _ResolveSchema : never type _ExtractComputed = C extends { computed: infer Comp } ? { [K in keyof Comp]: Comp[K] extends (...args: any[]) => infer R ? R : never } : Record type _ResolveComputed = M extends { Content: infer C } ? _ExtractComputed : M extends { default: { Content: infer C } } ? _ExtractComputed : M extends { default: infer D } ? _ExtractComputed : Record declare module 'vike-content-collection' { interface CollectionMap { 'blog': { metadata: z.infer<_ResolveContent> computed: _ResolveComputed } } } ``` This means: - getCollection('blog') returns entries typed with the exact Zod schema from pages/blog/+Content.ts - getCollectionEntry('blog', 'my-post') returns a properly typed entry (or undefined) - findCollectionEntries('blog', /pattern/) returns properly typed entries - Collection names autocomplete in your editor - Metadata fields are fully typed and autocomplete as well - Computed field return types are inferred from the functions in your +Content.ts No manual type annotations are needed anywhere. ## 3. Virtual module type declaration The plugin exposes all collection data through a Vite virtual module. If you import from virtual:content-collection in your TypeScript code, you need to declare its types manually. Create a declaration file (e.g. src/vite-env.d.ts or any .d.ts file included in your tsconfig.json): ```ts declare module 'virtual:content-collection' { export const collections: Record content: string computed: Record lastModified: string | undefined _isDraft: boolean }> }> } ``` This declaration is only needed if you actually import from virtual:content-collection. Most projects use getCollection(), getCollectionEntry(), and findCollectionEntries() instead, which are already fully typed through the auto-generated declaration file. ### When to use the virtual module The virtual module is useful for: - Other Vite plugins that need access to collection data - Custom build scripts or tooling that operate on the raw collection store - Cases where you need the serialized (JSON-compatible) form of all collections at once For typical page rendering and data loading, use getCollection(), getCollectionEntry(), and findCollectionEntries() in +data.ts files instead. ## Troubleshooting ### Types not updating If types appear stale, try these steps: 1. Restart the dev server -- the declaration file is regenerated on startup 2. Restart the TypeScript language server in your editor (in VS Code: Ctrl+Shift+P / Cmd+Shift+P → "TypeScript: Restart TS Server") 3. Check your tsconfig.json -- make sure .vike-content-collection/**/* is in the include array ### Collection name not autocompleting - Verify the dev server is running (the declaration file is generated at startup) - Check that .vike-content-collection/types.d.ts exists in your project root - Confirm the file is not empty -- it should contain declare module 'vike-content-collection' with your collection entries ### Cannot find module 'virtual:content-collection' Add the virtual module declaration as described in section 3 above. This error only applies when importing the virtual module directly. ## Complete setup checklist 1. Install vike-content-collection and peer dependencies 2. Register the Vite plugin in vite.config.ts with ssr.external 3. Extend the Vike config in +config.ts 4. Add ".vike-content-collection/**/*" to tsconfig.json include 5. Add .vike-content-collection/ to .gitignore 6. (Optional) Add a virtual:content-collection type declaration if you import the virtual module 7. Start the dev server -- types are generated automatically --- # Advanced Features This guide covers the plugin's advanced capabilities: computed fields, collection references, custom slugs, draft mode, sorting, pagination, grouping, breadcrumbs, next/previous navigation, TOC tree, collection entry tree, related entries, merge collections, unique values, entry URL, content series, i18n locales, git integration, and the virtual module. ## Computed fields Derive additional data from each entry. Computed functions run after schema validation and are available on the computed property of every entry. ### Defining computed fields Use the extended config format and add a computed object: ```ts // pages/blog/+Content.ts import { z } from 'zod' export const Content = { schema: z.object({ title: z.string(), date: z.date() }), computed: { readingTime: ({ content }) => Math.ceil(content.split(/\s+/).length / 200), wordCount: ({ content }) => content.split(/\s+/).length, excerpt: ({ content }) => content.slice(0, 160).trim() + '...', } } ``` Each function receives a ComputedFieldInput: - metadata (Record) -- Validated frontmatter - content (string) -- Raw markdown body - filePath (string) -- Absolute path to file - slug (string) -- Entry slug ### Accessing computed values ```ts import { getCollection } from 'vike-content-collection' const posts = getCollection('blog') for (const post of posts) { console.log(`${post.metadata.title} - ${post.computed.readingTime} min read`) } ``` ## Collection references Use reference() to create a Zod schema that validates a slug string and marks it as a reference to another collection. After all collections are loaded, the plugin verifies that the referenced slug exists. ### Defining a reference ```ts // pages/posts/+Content.ts import { z } from 'zod' import { reference } from 'vike-content-collection' export const Content = z.object({ title: z.string(), author: reference('authors'), }) ``` The author field accepts any string during initial validation. After all collections are processed, the plugin runs a cross-collection pass and warns if a slug doesn't exist in the "authors" collection. ### Using referenced data References are stored as slug strings. To resolve them, query the referenced collection: ```ts import { getCollectionEntry } from 'vike-content-collection' const post = getCollectionEntry('blog', 'my-post') if (post) { const author = getCollectionEntry('authors', post.metadata.author) console.log(author?.metadata.name) } ``` ### Example: blog with authors ```ts // pages/authors/+Content.ts export const Content = { type: 'data', schema: z.object({ name: z.string(), bio: z.string() }) } ``` ```yaml # pages/authors/jane.yaml name: Jane Doe bio: Writer and developer ``` ```ts // pages/blog/+Content.ts import { reference } from 'vike-content-collection' export const Content = z.object({ title: z.string(), author: reference('authors'), }) ``` ```md --- title: "My Post" author: "jane" --- Content here. ``` If the frontmatter says author: "nonexistent", the plugin warns that the slug doesn't exist in the "authors" collection. ## Custom slugs By default, slugs are derived from the filename without extension (e.g. hello-world.md becomes "hello-world"). Override slug generation with a slug function. Use defineCollection() for typed metadata: ```ts 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, // metadata is fully typed }) ``` The function receives a SlugInput: - metadata (inferred from schema via defineCollection) -- Validated frontmatter - filePath (string) -- Absolute path to the file - defaultSlug (string) -- Filename-based slug ## Draft mode Entries with a truthy draft field in their metadata are automatically handled: - Development -- drafts are included with _isDraft: true - Production (vite build) -- drafts are excluded entirely No schema changes are required. The plugin checks the metadata field directly after validation. ### Styling drafts in development ```tsx const posts = getCollection('blog') posts.map(post => (
{post._isDraft && [DRAFT]}

{post.metadata.title}

)) ``` ### Draft configuration ```ts vikeContentCollection({ drafts: { field: 'draft', // metadata field to check (default: "draft") includeDrafts: false, // force exclude even in dev (default: true in dev, false in prod) } }) ``` - drafts.field (string, default "draft") -- Metadata field name - drafts.includeDrafts (boolean, default true in dev / false in prod) -- Force include or exclude drafts ## Sorting ### sortCollection(entries, key, order?) Sort entries by a metadata key. Returns a new array without mutating the original: ```ts 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') // alphabetical ``` - Supports Date, number, and string values - Defaults to 'asc' (ascending) order - Returns a new array; the original is not modified ## Pagination ### paginate(entries, options) Split an array of entries into pages: ```ts import { getCollection, sortCollection, paginate } from 'vike-content-collection' const posts = getCollection('blog') const sorted = sortCollection(posts, 'date', 'desc') const page = paginate(sorted, { pageSize: 10, currentPage: 2 }) ``` ### PaginationResult - items (TypedCollectionEntry[]) -- Entries for the current page - currentPage (number) -- Current page number - totalPages (number) -- Total number of pages - totalItems (number) -- Total number of entries - hasNextPage (boolean) -- Whether a next page exists - hasPreviousPage (boolean) -- Whether a previous page exists The currentPage is automatically clamped to valid bounds (1 to totalPages). ### Pagination with Vike routing ```ts // pages/blog/@page/+data.ts import { getCollection, sortCollection, paginate } from 'vike-content-collection' import type { PageContext } from 'vike/types' export function data(pageContext: PageContext) { const posts = getCollection('blog') const sorted = sortCollection(posts, 'date', 'desc') const page = paginate(sorted, { pageSize: 10, currentPage: Number(pageContext.routeParams.page) || 1 }) return { page } } ``` ```tsx // pages/blog/@page/+Page.tsx import { useData } from 'vike-renderer/useData' export function Page() { const { page } = useData() return (
{page.items.map(post => (

{post.metadata.title}

))}
) } ``` ## Grouping ### groupBy(entries, key) Group entries by a metadata key. Returns a Map. If the metadata value is an array (e.g. tags), the entry appears in a group for each element. Entries where the key is undefined or null are skipped. ```ts import { getCollection, groupBy } from 'vike-content-collection' const posts = getCollection('blog') const byTag = groupBy(posts, 'tags') // Map { 'javascript' => [...], 'react' => [...], 'python' => [...] } const byCategory = groupBy(posts, 'category') // Map { 'tutorial' => [...], 'guide' => [...] } ``` ## Breadcrumbs ### getBreadcrumbs(collectionName, slug?, options?) Generate a breadcrumb trail from a collection name and optional entry slug. Collection names encode hierarchy ("docs/guides" splits into two segments). Each segment is resolved to a label via options.labels or title-cased from the segment name. ```ts import { getBreadcrumbs } from 'vike-content-collection' const crumbs = getBreadcrumbs('docs/guides', 'getting-started') // [ // { label: 'Docs', path: '/docs' }, // { label: 'Guides', path: '/docs/guides' }, // { label: 'Getting Started', path: '/docs/guides/getting-started' }, // ] ``` ### BreadcrumbOptions | Option | Type | Default | Description | | ---------------- | -------------------------- | -------- | -------------------------------------------------- | | labels | Record | {} | Map path segments to display labels | | basePath | string | "/" | Prefix prepended to all breadcrumb paths | | includeCurrent | boolean | true | Include the entry as the last breadcrumb | | currentLabel | string | -- | Override label for the current entry crumb | ### Custom labels and base path ```ts const crumbs = getBreadcrumbs('docs/guides', 'setup', { labels: { docs: 'Documentation', guides: 'User Guides' }, basePath: '/en', }) // [ // { label: 'Documentation', path: '/en/docs' }, // { label: 'User Guides', path: '/en/docs/guides' }, // { label: 'Setup', path: '/en/docs/guides/setup' }, // ] ``` ## Next/previous navigation ### getAdjacentEntries(name, currentSlug, options?) Find the previous and next entries relative to a given slug in a collection. Optionally sort entries by a metadata key before determining adjacency. ```ts import { getAdjacentEntries } from 'vike-content-collection' const { prev, next } = getAdjacentEntries('blog', 'my-post', { sortBy: 'date', order: 'desc', }) ``` | Option | Type | Default | Description | | -------- | ----------------- | ------- | ------------------------------------ | | sortBy | string | -- | Metadata key to sort by before lookup | | order | 'asc' | 'desc' | 'asc' | Sort direction | Both prev and next are TypedCollectionEntry | undefined. If the slug is not found, both are undefined. ### Usage in a page ```ts // pages/blog/@slug/+data.ts import { getAdjacentEntries, getCollectionEntry } from 'vike-content-collection' import type { PageContext } from 'vike/types' export function data(pageContext: PageContext) { const slug = pageContext.routeParams.slug const post = getCollectionEntry('blog', slug) const { prev, next } = getAdjacentEntries('blog', slug, { sortBy: 'date', order: 'desc', }) return { post, prev, next } } ``` ## Table of contents tree ### buildTocTree(headings) Convert a flat array of headings (from extractHeadings or renderEntry) into a nested tree structure. Each node has a children array for deeper headings. ```ts import { extractHeadings, buildTocTree } from 'vike-content-collection' const headings = await extractHeadings(post.content) const tree = buildTocTree(headings) ``` ### TocNode | Field | Type | Description | | ---------- | ----------- | ---------------------- | | depth | number | Heading level (1-6) | | text | string | Heading text | | id | string | Slug ID for linking | | children | TocNode[] | Nested child headings | For headings [H2, H3, H3, H2], buildTocTree returns: ```ts [ { depth: 2, text: 'Section A', id: 'section-a', children: [ { depth: 3, text: 'Sub 1', id: 'sub-1', children: [] }, { depth: 3, text: 'Sub 2', id: 'sub-2', children: [] }, ]}, { depth: 2, text: 'Section B', id: 'section-b', children: [] }, ] ``` ## Collection entry tree ### getCollectionTree(name) Returns the entries of a collection organised as a hierarchical tree based on slug paths. Useful for generating sidebars, site maps, or nested navigation from collections whose entries use path-based slugs (e.g. "guides/installation"). ```ts import { getCollectionTree } from 'vike-content-collection' const tree = getCollectionTree('docs') ``` ### TypedTreeNode The return type is `TypedTreeNode[]`, a discriminated union: `TypedEntryNode | TypedFolderNode`. Entry data is fully typed (metadata/computed inferred from CollectionMap). **TypedEntryNode** — a leaf node carrying a typed entry. | Field | Type | Description | | -------- | -------------------- | ----------------------------- | | name | string | Segment name (e.g. "intro") | | fullName | string | Full entry slug | | entry | TypedCollectionEntry | The typed collection entry | **TypedFolderNode** — a directory node containing children. | Field | Type | Description | | -------- | ---------------- | --------------------------------------------------------------- | | name | string | Segment name (e.g. "guides") | | fullName | string | Full entry slug if this folder is also an entry, otherwise "" | | children | TypedTreeNode[] | Child nodes | Distinguish with `"children" in node` (folder) or `"entry" in node` (leaf). Given a "docs" collection with entries "intro", "guides/installation", "guides/configuration", and "api/overview": ```ts [ { name: 'intro', fullName: 'intro', entry: { slug: 'intro', ... } }, { name: 'guides', fullName: '', children: [ { name: 'installation', fullName: 'guides/installation', entry: { ... } }, { name: 'configuration', fullName: 'guides/configuration', entry: { ... } }, ] }, { name: 'api', fullName: '', children: [ { name: 'overview', fullName: 'api/overview', entry: { ... } }, ] }, ] ``` If an entry exists at a path that also has children, the path becomes a FolderNode with fullName set to the slug. ## Related entries ### getRelatedEntries(name, currentSlug, options) Find entries related to a given entry by scoring shared metadata values across specified fields. Array fields like tags are compared element-by-element. ```ts import { getRelatedEntries } from 'vike-content-collection' const related = getRelatedEntries('blog', 'my-post', { by: ['tags', 'category'], limit: 3, }) ``` ### RelatedEntriesOptions | Option | Type | Default | Description | | ------- | ---------- | ------- | ---------------------------------------------- | | by | string[] | -- | Metadata fields to compare for overlap | | limit | number | 5 | Maximum number of related entries to return | Entries with zero overlap are excluded. Results are sorted by overlap score descending. ## Merge collections ### mergeCollections(names) Combine entries from multiple collections into a single array. Useful for aggregated views. ```ts import { mergeCollections, sortCollection } from 'vike-content-collection' const all = mergeCollections(['blog', 'news', 'changelog']) const latest = sortCollection(all, 'date', 'desc').slice(0, 10) ``` The metadata type of the result is Record since schemas may differ across collections. ## Unique values ### uniqueValues(entries, key) Extract all unique values for a metadata key across entries. Array fields are flattened. Returns a sorted, deduplicated array of strings. ```ts import { getCollection, uniqueValues } from 'vike-content-collection' const posts = getCollection('blog') const allTags = uniqueValues(posts, 'tags') // ['javascript', 'python', 'react', 'vue'] ``` ## Entry URL ### getEntryUrl(collectionName, slug, options?) Generate a URL path for a collection entry. ```ts import { getEntryUrl } from 'vike-content-collection' const url = getEntryUrl('docs/guides', 'getting-started') // '/docs/guides/getting-started' const url = getEntryUrl('blog', 'my-post', { basePath: '/en', extension: '.html' }) // '/en/blog/my-post.html' ``` ### EntryUrlOptions | Option | Type | Default | Description | | ----------- | -------- | ------- | ------------------------------------ | | basePath | string | "/" | Prefix prepended to the URL | | extension | string | "" | File extension appended to the slug | ## Content series ### getSeries(name, currentSlug, seriesName, options?) Get an ordered series of entries that share a common series identifier. Entries declare membership via metadata fields (e.g. series: "react-tutorial" and seriesOrder: 2). ```ts import { getSeries } from 'vike-content-collection' const series = getSeries('blog', 'part-2', 'react-tutorial') if (series) { console.log(`Part ${series.currentIndex + 1} of ${series.total}`) } ``` ### SeriesResult | Field | Type | Description | | -------------- | ------------------------------- | --------------------------------------- | | name | string | Series identifier | | entries | TypedCollectionEntry[] | All entries in order | | currentIndex | number | Zero-based index of current entry | | total | number | Total entries in the series | | prev | TypedCollectionEntry | undefined | Previous entry | | next | TypedCollectionEntry | undefined | Next entry | ### SeriesOptions | Option | Type | Default | Description | | ------------- | -------- | --------------- | ------------------------------------ | | seriesField | string | "series" | Metadata field for series name | | orderField | string | "seriesOrder" | Metadata field for sort order | Returns undefined if no entries match the series or the slug is not found within it. ## i18n locale helpers Helpers for multilingual content. Two locale detection strategies are supported: - suffix (default): locale is part of the slug (e.g. getting-started.fr) - metadata: locale is stored in a metadata field (e.g. locale: "fr") ### getAvailableLocales(name, baseSlug, options?) Get all available locales for a given base slug. ```ts import { getAvailableLocales } from 'vike-content-collection' const locales = getAvailableLocales('docs', 'getting-started') // ['', 'de', 'fr'] ('' = default locale / base slug) ``` ### getLocalizedEntry(name, baseSlug, locale, options?) Get a specific localized version of an entry. ```ts import { getLocalizedEntry } from 'vike-content-collection' const frEntry = getLocalizedEntry('docs', 'getting-started', 'fr') const defaultEntry = getLocalizedEntry('docs', 'getting-started', '') ``` ### LocaleOptions | Option | Type | Default | Description | | ----------- | ---------------------- | ---------- | ---------------------------------------- | | strategy | "suffix" | "metadata" | "suffix" | How to detect locales | | field | string | "locale" | Metadata field (metadata strategy only) | | separator | string | "." | Separator between slug and locale | ## Git last modified Populate the lastModified field on each entry using git log. This gives you the date of the last commit that touched each file. ### Enable it ```ts vikeContentCollection({ lastModified: true }) ``` ### Use it ```ts const posts = getCollection('blog') for (const post of posts) { if (post.lastModified) { console.log(`${post.metadata.title} -- updated ${post.lastModified.toLocaleDateString()}`) } } ``` lastModified is undefined if git is unavailable or the file is untracked (e.g. newly created and not yet committed). ## Virtual module The plugin exposes all collection data through a Vite virtual module. This is useful for other Vite plugins or when you need raw access to the collection store. ```ts import { collections } from 'virtual:content-collection' ``` collections is a record keyed by the directory path of each +Content.ts. Each value contains: - type ('content' | 'data' | 'both') -- Collection type - entries (Array) -- Array of serialized entry objects Each entry in the array has filePath, slug, metadata, content, computed, lastModified (ISO string or undefined), and _isDraft. ### TypeScript support for virtual module To use the virtual module in TypeScript, add a type declaration: ```ts // src/vite-env.d.ts declare module 'virtual:content-collection' { export const collections: Record content: string computed: Record lastModified: string | undefined _isDraft: boolean }> }> } ``` ## Server-only execution The plugin's runtime APIs (getCollection, getCollectionEntry, findCollectionEntries, renderEntry, etc.) use Node.js-specific code that should not run in the browser. The plugin handles this automatically: when vike-content-collection is imported in a client-side bundle, the plugin intercepts the import and replaces it with a lightweight no-op module that exports safe stubs. On the client side: - getCollection() returns [] - getCollectionEntry() returns undefined - findCollectionEntries() returns [] - renderEntry() returns { html: '', headings: [] } - All other runtime functions return safe empty values No additional configuration is needed. Use +data.ts files (which run on the server) to call the runtime APIs and pass data to your page components. ## Plugin options All options at a glance: ```ts vikeContentCollection({ contentDir: 'pages', contentRoot: 'content', declarationOutDir: '.vike-content-collection', declarationFileName: 'types.d.ts', drafts: { field: 'draft', includeDrafts: true, }, lastModified: true, }) ``` - contentDir (string, default "pages") -- Directory to scan for +Content.ts files - contentRoot (string, default same as contentDir) -- Directory where content/data files live - declarationOutDir (string, default ".vike-content-collection") -- Output directory for the generated TypeScript declaration file - declarationFileName (string, default "types.d.ts") -- Filename for the generated TypeScript declaration file - drafts.field (string, default "draft") -- Metadata field name for draft status - drafts.includeDrafts (boolean, default true in dev / false in prod) -- Force include or exclude draft entries - lastModified (boolean, default false) -- Populate lastModified from git history --- # API Reference ## Functions Exported from 'vike-content-collection': - vikeContentCollectionPlugin(options?) -- Vite plugin factory (also the default export) - getCollection(name) -- retrieve all entries of a collection, fully typed - getCollectionEntry(name, slug) -- look up a single entry by slug, returns entry or undefined - findCollectionEntries(name, filter) -- find entries by regex, predicate, or array of filters - renderEntry(entry, options?) -- render content to HTML using default or custom renderer, returns { html, headings } - extractHeadings(content) -- extract headings from markdown without full render - createMarkdownRenderer(defaults?) -- create a markdown renderer (default, used when no renderer specified) - createMdxRenderer(defaults?) -- create an MDX renderer for .mdx files with JSX syntax support - sortCollection(entries, key, order?) -- sort entries by a metadata key - paginate(entries, { pageSize, currentPage }) -- paginate an array of entries - reference(collectionName: CollectionName) -- create a cross-collection reference Zod schema (typed argument autocompletes to known collection names) - defineCollection(config) -- type-safe collection definition helper; infers metadata type for slug and computed callbacks ## Types Exported from 'vike-content-collection': - ContentCollectionPluginOptions -- Options for vikeContentCollection() - ContentCollectionConfig -- Shape of the +Content.ts export - ContentCollectionDefinition -- Extended config with schema, computed, slug, type, contentPath (generic over schema type) - ResolvedContentConfig -- Normalized config after resolving schema or definition - ComputedFieldInput -- Input to computed field functions (generic metadata type) - SlugInput -- Input to custom slug functions (generic metadata type) - CollectionMap -- Augmentable interface mapping collection names to types - CollectionName -- Union of known collection names (falls back to string before type generation) - TypedCollectionEntry -- A single collection entry with typed metadata and computed fields - CollectionEntryFilter -- Single filter: string, RegExp, or predicate - CollectionEntryFilterInput -- One or more filters for findCollectionEntries() - CollectionEntryPredicate -- 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 (render method) - RenderResult -- { html: string, headings: Heading[] } - RenderOptions -- Custom remarkPlugins, rehypePlugins, and optional renderer - Heading -- { depth: number, text: string, id: string } - PaginationResult -- Paginated result with items, page info, and navigation --- # How It Works 1. Scan -- finds +Content.ts files in contentDir on buildStart 2. Parse -- extracts YAML frontmatter from .md/.mdx files (via gray-matter), or reads .json/.yaml/.toml for data collections 3. Validate -- checks each entry against its Zod schema, mapping errors back to source line numbers 4. Compute -- runs computed field functions on validated entries 5. Filter -- excludes draft entries in production 6. Store -- holds entries in memory, keyed by collection name 7. References -- verifies cross-collection reference() slugs exist 8. Types -- emits .vike-content-collection/types.d.ts (or custom declarationOutDir) 9. Serve -- exposes data through virtual:content-collection 10. Client noop -- intercepts client-side imports and replaces them with safe no-op stubs 11. HMR -- incrementally re-processes changed files, regenerates types, and invalidates the virtual module