Skip to main content

guides/features/content-collections.md

# Content Collections

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

```elixir
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
```

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

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

A post with only a title:

```yaml
---
title: Hello
---
```

is exposed as normalized data with defaults:

```elixir
%{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:

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

Use image fields directly with Astral image components:

```astral
<.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:

```eex
<%= 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:

```text
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. 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:

```astral
<article>
  <h1>{@entry.data.title}</h1>
  {@entry.content}
  <p>Slug: {@params.slug}</p>
</article>
```

Nested collection slugs can use a glob route:

```text
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:

```md
# {@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:

```elixir
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:

```elixir
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:

```elixir
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:

```astral
---
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:

```astral
---
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.