leptos-content-collection
Astro-style content collections for Rust — define a schema struct, point it at a directory of Markdown files, get back a fully typed collection.
Overview
leptos-content-collection brings the content
collections concept popularised by
Astro
to the Rust ecosystem. Each directory of .md files
becomes a typed collection: your frontmatter is deserialised into a
Rust struct at compile time or load time, and the Markdown body can
be rendered to HTML on demand.
Features
Content files are parsed during cargo build and
embedded directly into the binary. No filesystem access at
runtime — works in SSR servers and WASM bundles alike.
Reads .md files from the filesystem at request time
via Collection::load(). Useful when you want to
update content without recompiling.
Both features can be active simultaneously. The table below summarises what each one enables:
| Feature | Default | Method unlocked |
|---|---|---|
buildtime |
✓ |
Collection::from_embedded(),
codegen::generate(), EmbeddedEntry
|
ssr |
— | Collection::load() |
Content Format
Every .md file must start with a YAML frontmatter block
delimited by ---. The slug for each
entry is the filename without the .md extension.
--- title: "Hello, World!" date: "2026-01-15" draft: false --- # Hello, World! Your Markdown content goes here. **Bold**, _italic_, `code`. ## A section More content...
| Part | Description |
|---|---|
First --- block
|
YAML frontmatter. Deserialised into your schema struct. |
| Body |
Raw Markdown. Accessible via entry.body and
renderable with entry.render().
|
| Slug |
Filename without .md, e.g.
"hello-world".
|
Styling rendered Markdown
entry.render() returns an HTML string. Render it inside
a wrapper element (for example, .md-content) and scope
your typography styles to that wrapper.
<article class="md-content"> <!-- Inject 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 processed during cargo build and embedded in
the binary as static string literals. No std::fs calls
happen at runtime, so the same binary works for both SSR servers and
WASM bundles.
[dependencies] leptos-content-collection = "0.1" # buildtime is the default feature [build-dependencies] leptos-content-collection = "0.1" # needed by build.rs
build.rs
codegen::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 so the build
re-runs whenever a content file is added, edited, or removed.
fn main() { leptos_content_collection::codegen::generate( "content/posts", "posts", ).unwrap(); }
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 } } }
SSR / Runtime
Enable the ssr feature to read files from the
filesystem at request time. Useful when you want to edit content
without rebuilding.
[dependencies] leptos-content-collection = { version = "0.1", 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 } } }
Collection::load() uses std::fs and will
not compile for wasm32 targets. In a Leptos project,
call it only inside #[server] functions or Axum
handlers.
Leptos Integration
A typical Leptos + Axum setup uses both features:
buildtime so the WASM bundle can access data without a
server round-trip, and ssr (optionally) inside
#[server] functions for hot-reloadable content in
development.
[dependencies] # buildtime is the default — works for both SSR and WASM leptos-content-collection = { path = "…" } [build-dependencies] leptos-content-collection = { path = "…" } [features] ssr = [ # …other ssr deps… "leptos-content-collection/ssr", ]
fn main() { leptos_content_collection::codegen::generate("content/posts", "posts").unwrap(); }
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, } // Synchronous — no server function needed, works on both server and client. fn load_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() } #[component] fn BlogPage() -> impl IntoView { let posts = load_posts(); view! { <ul> {posts.into_iter().map(|p| view! { <li>{p.title}</li> }).collect_view()} </ul> } }
codegen::generate is gated with
cfg(not(target_arch = "wasm32")) inside the crate, so
it is never compiled into the WASM bundle — only into
build.rs
(which runs on the host).
Yew Integration
Yew compiles to WASM, so only the buildtime feature
works — content is embedded at compile time and requires no
filesystem access at runtime.
[dependencies] leptos-content-collection = "0.1" # buildtime is the default feature yew = { version = "0.21", features = ["csr"] } serde = { version = "1", features = ["derive"] } [build-dependencies] leptos-content-collection = "0.1"
fn main() { leptos_content_collection::codegen::generate("content/posts", "posts").unwrap(); }
use yew::prelude::*; use serde::Deserialize; use leptos_content_collection::{Collection, EmbeddedEntry}; #[derive(Deserialize, Clone)] struct Post { title: String, date: String, draft: bool, } #[function_component(App)] fn app() -> Html { static ENTRIES: &[EmbeddedEntry] = include!(concat!(env!("OUT_DIR"), "/posts_collection.rs")); let posts = Collection::<Post>::from_embedded(ENTRIES) .unwrap() .into_entries(); html! { <ul> { for posts.iter().filter(|p| !p.data.draft).map(|p| html! { <li> <strong>{ &p.data.title }</strong> { " — " }{ &p.data.date } </li> }) } </ul> } } fn main() { yew::Renderer::<App>::new().render(); }
ssr feature uses std::fs and will
not compile for wasm32 targets. Stick with
buildtime for pure-WASM Yew apps.
Dioxus Integration
Dioxus supports multiple renderers (web, desktop, mobile). For WASM
targets use
buildtime; for desktop / native targets both features
work.
[dependencies] leptos-content-collection = "0.1" # buildtime is the default feature dioxus = { version = "0.6", features = ["web"] } serde = { version = "1", features = ["derive"] } [build-dependencies] leptos-content-collection = "0.1"
use dioxus::prelude::*; use serde::Deserialize; use leptos_content_collection::{Collection, EmbeddedEntry}; #[derive(Deserialize, Clone)] struct Post { title: String, date: String, draft: bool, } #[component] fn BlogList() -> Element { static ENTRIES: &[EmbeddedEntry] = include!(concat!(env!("OUT_DIR"), "/posts_collection.rs")); let posts = Collection::<Post>::from_embedded(ENTRIES) .unwrap() .into_entries(); rsx! { ul { for post in posts.into_iter().filter(|p| !p.data.draft) { li { strong { { post.data.title.clone() } } span { " — {post.data.date}" } } } } } } fn main() { dioxus::web::launch(BlogList); }
[dependencies] leptos-content-collection = { version = "0.1", features = ["ssr"] } dioxus = { version = "0.6", features = ["desktop"] } serde = { version = "1", features = ["derive"] }
use dioxus::prelude::*; use serde::Deserialize; use leptos_content_collection::Collection; #[derive(Deserialize, Clone)] struct Post { title: String, draft: bool, } #[component] fn BlogList() -> Element { // Reads .md files from disk at runtime — only works on native targets let posts = Collection::<Post>::load("content/posts") .unwrap() .into_entries(); rsx! { ul { for post in posts.into_iter().filter(|p| !p.data.draft) { li { { post.data.title.clone() } } } } } } fn main() { dioxus::desktop::launch(BlogList); }
buildtime and ssr features are
available.
Freya Integration
Freya is a native cross-platform GUI framework for Rust, so it can
use
both crate features. Use buildtime for
embedded content in production builds, and optionally
ssr in native targets when you want live filesystem
content during development.
[dependencies] freya = "0.4.0-rc.15" leptos-content-collection = "0.1" # buildtime is default serde = { version = "1", features = ["derive"] } [build-dependencies] leptos-content-collection = "0.1"
fn main() { leptos_content_collection::codegen::generate("content/posts", "posts").unwrap(); }
use freya::prelude::*; use serde::Deserialize; use leptos_content_collection::{Collection, EmbeddedEntry}; #[derive(Deserialize, Clone)] struct Post { title: String, date: String, draft: bool, } fn load_posts() -> Vec<Post> { static ENTRIES: &[EmbeddedEntry] = include!(concat!(env!("OUT_DIR"), "/posts_collection.rs")); Collection::<Post>::from_embedded(ENTRIES) .unwrap() .into_entries() .into_iter() .map(|entry| entry.data) .filter(|post| !post.draft) .collect() } fn app() -> impl IntoElement { let posts = load_posts(); rect() .vertical() .padding(16.) .spacing(8.) .children(posts.into_iter().map(|post| { label().text(format!("{} - {}", post.date, post.title)).into() })) } fn main() { launch(app); }
ssr (optional)
[dependencies] freya = "0.4.0-rc.15" leptos-content-collection = { version = "0.1", features = ["ssr"] } serde = { version = "1", features = ["derive"] }
use freya::prelude::*; use serde::Deserialize; use leptos_content_collection::Collection; #[derive(Deserialize, Clone)] struct Post { title: String, draft: bool, } fn app() -> impl IntoElement { let posts = Collection::<Post>::load("content/posts") .unwrap() .into_entries(); rect() .vertical() .padding(16.) .spacing(8.) .children( posts .into_iter() .filter(|post| !post.data.draft) .map(|post| label().text(post.data.title).into()), ) } fn main() { launch(app); }
wasm32 today.
That means Collection::load() is available for
desktop/native apps, while
Collection::from_embedded() remains ideal for
self-contained binaries.
Collection<T>
The main type. T is your schema struct — it must
implement serde::Deserialize.
| Method | Feature | Description |
|---|---|---|
Collection::load(dir) |
ssr |
Reads all .md files from dir at
runtime. Returns
Result<Self, CollectionError>.
|
Collection::from_embedded(entries) |
buildtime |
Builds a collection from a
&[EmbeddedEntry] produced by
codegen::generate. Returns
Result<Self, CollectionError>.
|
collection.entries() |
— |
Returns &[CollectionEntry<T>] in load
order.
|
collection.into_entries() |
— |
Consumes the collection and returns
Vec<CollectionEntry<T>>.
|
CollectionEntry<T>
A single entry in a collection.
| Field / Method | Type | Description |
|---|---|---|
entry.slug |
String |
Filename without extension, e.g. "hello-world".
|
entry.data |
T |
The deserialized frontmatter matching your schema struct. |
entry.body |
String |
Raw Markdown body (everything after the closing
---).
|
entry.render() |
String |
Renders body to an HTML string using
pulldown-cmark with all extensions enabled.
|
EmbeddedEntry
The element type of the array written by
codegen::generate. You generally only interact with it
indirectly via Collection::from_embedded, but the
fields are public if you need to inspect them.
| Field | Type | Description |
|---|---|---|
slug |
&'static str |
Filename without extension. |
frontmatter_yaml |
&'static str |
Raw YAML string between the --- delimiters.
|
body |
&'static str |
Raw Markdown body. |
CollectionError
pub enum CollectionError { /// An IO error occurred while reading a file. (ssr only) Io(std::io::Error), /// A file is missing its `---` frontmatter block. Includes the file path. MissingFrontmatter(String), /// The YAML frontmatter could not be deserialised into `T`. InvalidFrontmatter { path: String, source: serde_yml::Error, }, }
codegen::generate
Available only on non-WASM targets when the
buildtime feature is active. Intended to be called from
build.rs.
pub fn generate( dir: impl AsRef<Path>, output_name: &str, ) -> Result<(), Box<dyn std::error::Error>>
| Parameter | Description |
|---|---|
dir |
Path to the directory containing .md files,
relative to the crate root.
|
output_name |
Name used for the generated file:
$OUT_DIR/{output_name}_collection.rs.
|
Also emits cargo:rerun-if-changed for the directory and
each individual .md file so incremental builds work
correctly.
Dependencies
| Crate | Purpose |
|---|---|
serde |
Deserialising frontmatter YAML into your schema struct. |
serde_yml |
YAML parsing. |
pulldown-cmark |
Markdown → HTML rendering, with all extensions enabled. |
thiserror |
Error type derivation. |
No Leptos dependency. No async runtime. No macros beyond standard derive.