Content Collections
Pattern for grouping related markdown content files (guides, posts, docs) under a single directory,
can be copied into a CLAUDE.md/AGENTS.md file for agents to follow.
Structure
- A
ContentCollectionclass wraps a single root directory of markdown files, with content types as subdirectories (e.g.content/guides/,content/posts/). Prefer one instance for the whole root over one instance per type, so the type names don't get hardcoded across the app. - A slug is a file's path relative to the root, without the
.mdextension, e.g.guides/drizzle. Because the slug carries its own type prefix, it doubles as the item's URL and callers don't need to pass the type separately. get(slug)reads and parses a single item. Throws if no matching file exists.list(filter?)returns lightweight{ slug, title }summaries, for building index/listing pages without parsing full content. Skips files prefixed with_and any non-.mdfiles. The optionalfilteris a partial path (e.g.guides/) that narrows results to one subtree.
Implementation
// src/lib/content-collection.types.ts
export interface ItemSummary {
slug: string;
title: string;
}// src/lib/content-collection.server.ts
import { readdir, readFile } from "fs/promises";
import { parseMarkdown } from "@tanstack/markdown";
import type { ItemSummary } from "./content-collection.types";
const TITLE_REGEX = /^#\s+(.+)$/m;
const MARKDOWN_EXTENSION_REGEX = /\.md$/;
/**
* A content collection is a group of related content files,
* stored in the same directory.
*
* @example
* ```ts
* const content = new ContentCollection("./content/");
* const guides = await content.list("guides/");
* const guide = await content.get(guides[0].slug);
* ```
*/
export class ContentCollection {
private path: string;
/**
* Creates a new content collection.
*
* @param path - The path to the directory containing the content, relative to the project root, e.g. `./content/posts/`
*/
constructor(path: string) {
this.path = path;
}
/**
* Reads and parses a single content item by its slug.
*
* @param slug - The item's filename without the `.md` extension
*
* @remarks
* Throws if no matching markdown file exists in the collection's directory.
*/
public async get(slug: string) {
const text = await readFile(`${this.path}/${slug}.md`, "utf-8");
return parseMarkdown(text);
}
/**
* Lists all content items in the collection.
*
* @param filter - Optional partial path; only items whose slug starts with it are returned
*
* @remarks
* Skips files whose names start with `_`, and files that don't end in `.md`.
*/
public async list(filter?: string): Promise<ItemSummary[]> {
const files = await readdir(this.path, { recursive: true });
// Prefix check runs first so non-matching paths skip the more expensive extension checks.
const markdownFiles = files.filter(
(file) =>
(!filter || file.startsWith(filter)) && file.endsWith(".md") && !file.startsWith("_"),
);
return Promise.all(
markdownFiles.map(async (file) => {
const slug = file.replace(MARKDOWN_EXTENSION_REGEX, "");
const title = await this.getMarkdownTitle(`${this.path}/${slug}.md`);
return { slug, title };
}),
);
}
/**
* Extracts the title from a markdown file's first level-1 heading.
*
* @param filePath - Absolute or relative path to the markdown file
*
* @remarks
* Throws if the file has no `# ` heading.
*/
private async getMarkdownTitle(filePath: string) {
const text = await readFile(filePath, "utf-8");
const match = text.match(TITLE_REGEX);
if (!match?.[1]) {
throw new Error(`No title found in markdown file: ${filePath}`);
}
return match[1].trim();
}
}Usage
// src/lib/content-collection.server.ts
export const CONTENT = new ContentCollection("./content/");// route loader, for a listing page scoped to one type
const guides = await CONTENT.list("guides/");// route loader for a catch-all splat route, e.g. src/routes/$.tsx
// params._splat is already the slug, e.g. "guides/drizzle"
const item = await CONTENT.get(params._splat);