Collections group Markdown entries such as posts, docs, changelog items, or authors. See the Markdown, content, and data guide for the broader boundary around Astro-style Markdown imports, loaders, data fetching, and live collections.
Define a collection
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
endEach 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. Declare a schema when you want fields in entry.data; collections without a schema expose %{} as normalized data while preserving raw frontmatter in entry.metadata. Astral's current collections are local Markdown collections; Astro-style content loaders, single-file JSON/YAML/TOML loaders, collection references, generated TypeScript types, and live collections are not implemented yet.
Schema defaults
Field defaults are applied to entry.data during schema normalization:
schema do
field :title, :string, required: true
field :draft, :boolean, default: false
field :tags, {:array, :string}, default: []
endA post with only a title:
---
title: Hello
---is exposed as normalized data with defaults:
%{title: "Hello", draft: false, tags: []}entry.metadata remains the original string-keyed frontmatter map. entry.data contains schema-declared fields only. Use entry.data in layouts, pages, feeds, generated routes, and collection helpers when you want cast values and defaults.
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].astralThe dynamic page route matches the collection permalink /blog/:slug/ and receives @entry plus string-keyed route params. Render the entry body directly with {@entry.content}; Astral content implements Phoenix's HTML-safe protocol, so already-rendered Markdown and Markdown components are not escaped:
<article>
<h1>{@entry.data.title}</h1>
{@entry.content}
<p>Slug: {@params.slug}</p>
</article>Nested collection slugs can use a glob route:
content/docs/guide/intro.md
pages/docs/[...path].mdUse @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()
}
endUse 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)
endCollection 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 schema-normalized entry.data and dynamic pages instead of waiting for a core taxonomy abstraction. Declare fields such as tags in your collection schema so helpers can use normalized atom-keyed data.
For a tag index page, ordinary .astral pages can read collection data directly:
---
posts = Astral.Collection.entries(@site, :posts)
assigns = assign(assigns, :tags, Astral.Collection.tags(posts))
---
<ul>
<li :for={tag <- @tags}>
<a href={"/tags/#{tag}/"}>{tag}</a>
</li>
</ul>For one generated page per tag, create a dynamic .astral page and declare its paths in the setup block:
---
posts = Astral.Collection.entries(@site, :posts)
paths =
for tag <- Astral.Collection.tags(posts) do
posts_for_tag = Enum.filter(posts, &(tag in &1.data.tags))
path tag: tag, assigns: %{posts: posts_for_tag}
end
---
<h1>{@params.tag}</h1>
<ul>
<li :for={post <- @posts}>
<a href={post.route_path}>{post.data.title}</a>
</li>
</ul>Save this as pages/tags/[tag].astral. Each item in paths is an Astral.Route.Path contract produced by path/1, not an arbitrary map. The path/1 params, rendered @params, and page assigns all use atom keys.