Content Collections

Copy Markdown View Source

Collections group Markdown entries such as posts, docs, changelog items, or authors.

Define a collection

site do
  collections do
    collection :posts, "content/posts" do
      permalink "/blog/:slug/"
      layout "post.html"

      schema do
        field :title, :string, required: true
        field :date, :date, required: true
        field :draft, :boolean, default: false
        field :tags, {:array, :string}, default: []
        field :cover, :image
      end
    end
  end
end

Each Markdown file in content/posts/ becomes a validated entry and a static page at the collection permalink. The schema do field DSL mirrors Ecto's field shape; Astral uses Ecto casting behind the scenes for types, required fields, defaults, and image source resolution.

Image fields resolve local paths relative to the entry file and expose an Astral.Image.Source struct:

---
title: Hello
cover: ./cover.jpg
---

Use image fields directly with Astral image components:

<.image src={@entry.data.cover} alt={@entry.data.title} width={800} />

Entry data

entry.metadata contains original string-keyed frontmatter. entry.data contains schema-normalized values:

<%= for post <- @collections.posts do %>
  <a href={post.route_path}><%= post.data.title %></a>
<% end %>

Collection entry layouts receive @entry for the current entry.

Dynamic detail pages

By default, collection entries render their own Markdown body through the configured layout. Add a matching dynamic file route when you want a page template to own the detail page HTML:

content/posts/hello.md
pages/blog/[slug].astral

The dynamic page route matches the collection permalink /blog/:slug/ and receives @entry plus string-keyed route params:

<article>
  <h1>{@entry.data.title}</h1>
  <p>Slug: {@params["slug"]}</p>
</article>

Nested collection slugs can use a glob route:

content/docs/guide/intro.md
pages/docs/[...path].md

Use @params["path"] to read the captured path.

Components in collection Markdown

Collection Markdown can use local .astral components with HEEx syntax:

# {@entry.data.title}

<.callout>
  Rendered from collection Markdown.
</.callout>

This is Astral's Markdown-component path. Use local component syntax (<.callout>) instead of MDX imports.

JSONSpec and Zoi schemas

JSONSpec-style typespec maps are also supported:

collection :posts, "content/posts" do
  schema %{
    required(:title) => String.t(),
    required(:date) => String.t(),
    optional(:draft) => boolean()
  }
end

Use Zoi when runtime coercion or refinements are useful:

collection :posts, "content/posts" do
  schema Zoi.map(%{
    title: Zoi.string(),
    tags: Zoi.array(Zoi.string()) |> Zoi.optional()
  }, coerce: true)
end

Collection helpers

Astral includes helpers for common entry filtering and sorting:

posts =
  @site
  |> Astral.Collection.entries(:posts)
  |> Astral.Collection.published()
  |> Astral.Collection.sort_by_date(:desc)

Tags and categories are userland. If your site needs tag pages, build them from collection data and generated routes.