leptos-content-collection
March 23, 2026 · View on GitHub
Astro-style content collections for Rust.
Define a schema struct, point it at a directory of Markdown files, and get back a fully typed collection — frontmatter validated against your struct and Markdown rendered to HTML on demand.
Despite the name, the crate has no dependency on Leptos and works in any Rust project (Axum, Actix-web, static site generators, CLIs, etc.).
serde usage in examples (such as #[derive(Deserialize)]) comes from the serde crate itself, not from any web framework.
Features
| Feature | Default | Description |
|---|---|---|
buildtime | ✅ | Embed content in the binary at compile time via build.rs |
ssr | ❌ | Load content from the filesystem at runtime (commonly used in server/native environments) |
Both features can be active at the same time.
What this crate is (and is not)
- It is a typed content loader for Markdown + frontmatter.
- It is not a templating engine replacement.
- It can be used with templating engines, component frameworks, or plain string rendering.
- If your workflow is "convert Markdown to JSON and deserialize with serde", that is a valid alternative; this crate exists to skip that conversion step and read Markdown collections directly.
Content format
Every .md file in your collection must begin with a YAML frontmatter block delimited by ---:
---
title: "Hello, World!"
date: "2026-01-15"
draft: false
---
# Hello, World!
Your Markdown content goes here.
The slug for each entry is the filename without the .md extension (e.g. hello-world).
Styling rendered Markdown
entry.render() returns an HTML string. A common pattern is to render that HTML inside a wrapper element (for example, .md-content) and scope your typography styles to that wrapper.
<article class="md-content">
<!-- Rendered HTML from entry.render() -->
</article>
.md-content {
line-height: 1.7;
color: #222;
}
.md-content h1,
.md-content h2,
.md-content h3 {
line-height: 1.25;
margin: 2rem 0 0.75rem;
}
.md-content h2 {
border-bottom: 1px solid #ebebeb;
padding-bottom: 0.35rem;
}
.md-content p {
margin: 1rem 0;
}
.md-content a {
color: #0066cc;
text-underline-offset: 2px;
}
.md-content pre {
background: #0d1117;
color: #e6edf3;
border-radius: 10px;
padding: 1rem 1.25rem;
overflow-x: auto;
}
.md-content code {
font-family: "Fira Code", "JetBrains Mono", ui-monospace, monospace;
}
.md-content table {
width: 100%;
border-collapse: collapse;
}
.md-content th,
.md-content td {
border: 1px solid #e0e0e0;
padding: 0.55rem 0.8rem;
}
If your content can come from untrusted sources, sanitize the generated HTML before injecting it in the browser.
Buildtime (default)
Content is parsed during cargo build and embedded directly into the binary. No filesystem access is required at runtime, so it works in both SSR servers and WASM bundles.
1. Add the dependency
# Cargo.toml
[dependencies]
leptos-content-collection = "1.2"
[build-dependencies]
leptos-content-collection = "1.2"
2. Create a build.rs
fn main() {
leptos_content_collection::codegen::generate("content/posts", "posts").unwrap();
}
build.rs is required in buildtime mode because code generation happens during compilation.
codegen::generate writes Rust source into $OUT_DIR, and your app includes that generated file with include!(...).
generate(dir, name) scans dir for .md files, embeds their content and writes
$OUT_DIR/{name}_collection.rs. It also emits cargo:rerun-if-changed directives
so the build re-runs whenever a file is added, edited, or removed.
3. Define your schema and load
use serde::Deserialize;
use leptos_content_collection::{Collection, EmbeddedEntry};
#[derive(Deserialize)]
struct Post {
title: String,
date: String,
draft: bool,
}
fn main() {
let posts = Collection::<Post>::from_embedded(
include!(concat!(env!("OUT_DIR"), "/posts_collection.rs")),
)
.unwrap();
for entry in posts.entries() {
if !entry.data.draft {
println!("{} — {}", entry.slug, entry.data.title);
println!("{}", entry.render()); // Markdown → HTML
}
}
}
Runtime filesystem loading (ssr feature)
Enable the ssr feature to read files from the filesystem at request time. Useful when you want to update content without recompiling.
Note: the feature name is ssr, but this mode only means "load from disk at runtime". It does not perform rendering by itself.
# Cargo.toml
[dependencies]
leptos-content-collection = { version = "1.2", features = ["ssr"] }
use serde::Deserialize;
use leptos_content_collection::Collection;
#[derive(Deserialize)]
struct Post {
title: String,
date: String,
draft: bool,
}
fn main() {
let posts = Collection::<Post>::load("content/posts").unwrap();
for entry in posts.entries() {
if !entry.data.draft {
println!("{} — {}", entry.slug, entry.data.title);
println!("{}", entry.render()); // Markdown → HTML
}
}
}
Usage with Leptos
A typical Leptos + Axum setup can use both features: buildtime so the WASM bundle has embedded data, and ssr inside #[server] functions only when runtime disk loading is desired.
Cargo.toml
[dependencies]
leptos-content-collection = "1.2.0" # buildtime is the default
[build-dependencies]
leptos-content-collection = "1.2.0" # for build.rs codegen
[features]
ssr = [
# …other ssr deps…
"leptos-content-collection/ssr", # enable Collection::load()
]
build.rs
fn main() {
leptos_content_collection::codegen::generate("content/posts", "posts").unwrap();
}
app.rs
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use leptos_content_collection::{Collection, EmbeddedEntry};
#[derive(Deserialize, Serialize, Clone)]
struct PostFrontmatter {
title: String,
date: String,
description: String,
}
// Works on both SSR and WASM — no server function needed.
fn get_posts() -> Vec<PostFrontmatter> {
static ENTRIES: &[EmbeddedEntry] =
include!(concat!(env!("OUT_DIR"), "/posts_collection.rs"));
Collection::<PostFrontmatter>::from_embedded(ENTRIES)
.unwrap()
.into_entries()
.into_iter()
.map(|e| e.data)
.collect()
}
API reference
Collection<T>
| Method | Feature | Description |
|---|---|---|
Collection::load(dir) | ssr | Reads .md files from dir at runtime (filesystem) |
Collection::from_embedded(entries) | buildtime | Builds a collection from compile-time embedded data |
collection.entries() | — | Returns &[CollectionEntry<T>] |
collection.into_entries() | — | Consumes the collection, returns Vec<CollectionEntry<T>> |
CollectionEntry<T>
| Field / Method | Description |
|---|---|
entry.slug | Filename without extension (e.g. "hello-world") |
entry.data | Deserialized frontmatter — your schema struct |
entry.body | Raw Markdown body |
entry.render() | Renders body to an HTML string |
EmbeddedEntry
The type of each element in the array generated by codegen::generate. You only interact with it through Collection::from_embedded.
| Field | Type | Description |
|---|---|---|
slug | &'static str | Filename without extension |
frontmatter_yaml | &'static str | Raw YAML between the --- delimiters |
body | &'static str | Raw Markdown body |
CollectionError
pub enum CollectionError {
Io(std::io::Error), // ssr only
MissingFrontmatter(String), // file path
InvalidFrontmatter { path, source }, // serde_yml parse error
}
codegen::generate
pub fn generate(
dir: impl AsRef<Path>,
output_name: &str,
) -> Result<(), Box<dyn std::error::Error>>
Available on non-WASM targets when the buildtime feature is active.
Writes $OUT_DIR/{output_name}_collection.rs and emits cargo:rerun-if-changed
for the directory and every .md file inside it.
Dependencies
| Crate | Purpose |
|---|---|
serde | Deserializing frontmatter into your schema struct |
serde_yml | YAML parsing |
pulldown-cmark | Markdown → HTML rendering |
thiserror | Error type derivation |
License
MIT