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.

crates.io v0.1.0 MIT framework-agnostic

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.

Despite the name, this crate has no dependency on Leptos. It works equally well with Axum, Actix-web, static site generators, CLIs, or any other Rust project.

Features

buildtime default

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.

ssr

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.

content/posts/hello-world.md
---
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.

html
<article class="md-content">
  <!-- Inject rendered HTML from entry.render() -->
</article>
app.css
.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.

1
Add the dependency
Cargo.toml
[dependencies]
leptos-content-collection = "0.1"  # buildtime is the default feature

[build-dependencies]
leptos-content-collection = "0.1"  # needed by build.rs
2
Create a 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.

build.rs
fn main() {
    leptos_content_collection::codegen::generate(
        "content/posts",
        "posts",
    ).unwrap();
}
3
Define your schema and load
rust
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.

Cargo.toml
[dependencies]
leptos-content-collection = { version = "0.1", features = ["ssr"] }
rust
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.

Cargo.toml
Cargo.toml
[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",
]
build.rs
build.rs
fn main() {
    leptos_content_collection::codegen::generate("content/posts", "posts").unwrap();
}
app.rs — using buildtime data (works in SSR + WASM)
rust
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.

Cargo.toml
Cargo.toml
[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"
build.rs
build.rs
fn main() {
    leptos_content_collection::codegen::generate("content/posts", "posts").unwrap();
}
app.rs — Yew component
rust
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();
}
The 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.

Web (WASM) — buildtime
Cargo.toml
[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"
rust
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);
}
Desktop — ssr feature (runtime filesystem)
Cargo.toml
[dependencies]
leptos-content-collection = { version = "0.1", features = ["ssr"] }
dioxus = { version = "0.6", features = ["desktop"] }
serde = { version = "1", features = ["derive"] }
rust
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);
}
Freya (a native GUI framework built on Dioxus) follows the same pattern as the Desktop example above — it runs natively so both 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.

Buildtime (recommended)
Cargo.toml
[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"
build.rs
fn main() {
    leptos_content_collection::codegen::generate("content/posts", "posts").unwrap();
}
rust
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);
}
Runtime content with ssr (optional)
Cargo.toml
[dependencies]
freya = "0.4.0-rc.15"
leptos-content-collection = { version = "0.1", features = ["ssr"] }
serde = { version = "1", features = ["derive"] }
rust
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);
}
Freya does not target 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

rust
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.

rust
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.