Skip to main content

guides/features/content-and-data.md

# Markdown, Content, and Data Fetching

Astral supports Markdown pages, Markdown-backed content collections, `.astral` templates, and build-time Elixir data loading. This guide maps Astro's Markdown, content collection, and data-fetching features to Astral's current APIs.

## Markdown pages

Markdown files in `pages/` become routes:

```text
pages/index.md      -> /
pages/about.md      -> /about/
pages/blog/post.md  -> /blog/post/
```

Use YAML frontmatter for page metadata and route options:

```md
---
title: About
layout: default.html
permalink: /about-us/
---

# About
```

Astral uses MDEx for Markdown and `YamlElixir` for frontmatter. TOML frontmatter is not supported today.

## Components in Markdown

Markdown pages and collection entries can use local `.astral` components through HEEx syntax:

```md
# Project

<.callout>
  Rendered by `components/callout.astral`.
</.callout>
```

Use local component calls such as `<.callout>`, not MDX imports. MDX/JSX expressions are not currently supported in Astral Markdown.

Assigns are available with HEEx expression syntax. `@entry` is present for collection entry pages, and `@entry.data` contains only schema-declared fields:

```md
<p>{@metadata["title"]}</p>
<p>{@entry.data.title}</p>
```

Prefer static Markdown heading text. Heading IDs and `@page.headings` are generated before HEEx expressions are evaluated.

## Rendering Markdown content

Collection detail pages can render the current entry's already-rendered Markdown safely:

```astral
<article>
  <h1>{@entry.data.title}</h1>
  {@entry.content}
</article>
```

`@entry.content` implements Phoenix's HTML-safe protocol. Prefer this over raw string injection.

Astral does not currently expose Astro-style Markdown file imports, `compiledContent()`, `rawContent()`, `<Content />`, or `import.meta.glob()` for Markdown. Use collection helpers, ordinary Elixir file APIs, or Volt browser imports depending on the layer you are working in.

## Content collections today

Define local Markdown collections in `astral.config.exs`:

```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: []
  end
end
```

Each Markdown file becomes an entry. Use a schema to expose cast atom-keyed values and defaults in `entry.data`; without a schema, `entry.data` is `%{}`. `entry.metadata` remains the original string-keyed frontmatter.

Collection helpers read schema-normalized data: `published/1` uses `entry.data[:draft]`, `sort_by_date/2` uses `entry.data[:updated]` or `entry.data[:date]`, and `tags/1` uses `entry.data[:tags]`. Declare these fields in your schema when you want helpers or templates to see them. Prefer `entry.data` in your own layouts, pages, feeds, and generated routes when you need schema defaults or cast values.

Query collections from `.astral` setup blocks, layouts, generated routes, or plugins:

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

assigns = assign(assigns, :posts, posts)
---

<ul>
  <li :for={post <- @posts}>
    <a href={post.route_path}>{post.data.title}</a>
  </li>
</ul>
```

Astral does not yet provide Astro's content-loader system, single-file JSON/YAML/TOML collection loaders, collection references, generated TypeScript types, live collections, or request-time collection queries.

## Generated routes from content

For one page per collection entry, use a collection `permalink` and optional matching dynamic `.astral` file route:

```text
content/posts/hello.md     -> /blog/hello/
pages/blog/[slug].astral   -> /blog/:slug
```

For arbitrary derived routes such as tag pages, use setup-declared dynamic `.astral` paths:

```astral
---
posts = Astral.Collection.entries(@site, :posts)

paths =
  for tag <- Astral.Collection.tags(posts) do
    matching = Enum.filter(posts, &(tag in &1.data.tags))
    path tag: tag, assigns: %{posts: matching}
  end
---

<h1>{@params.tag}</h1>
```

This is the current Astral equivalent of Astro's build-time static path generation, expressed as Elixir data in the page that owns the route.

## Build-time data fetching

In static output mode, data loaded during discovery or rendering is build-time data. Use ordinary Elixir libraries such as `Req`, `File`, `Path`, `Jason`, database clients, or service SDKs in setup blocks, config-generated routes, or plugins.

Example one-off JSON output from fetched data:

```elixir
get "/products.json", content_type: "application/json" do
  products = Req.get!("https://api.example.com/products").body
  Jason.encode!(products)
end
```

Example page setup data:

```astral
---
response = Req.get!("https://api.example.com/status")
assigns = assign(assigns, :status, response.body)
---

<p>{@status["message"]}</p>
```

During a static build, this runs when the page or route is generated. During development, it runs when Astral renders the page or generated route. Do not put secrets in browser assets; use server-side Elixir environment access for build-time data and Volt `import.meta.env` only for public browser values.

## Remote content and CMS data

Astral does not yet have a first-class remote content loader API. For CMS or API content today, choose the shape that matches your site:

- fetch data in a plugin during discovery and add generated routes,
- fetch inside a config-level `get` route for static JSON, feeds, indexes, or generated images,
- fetch in `.astral` setup for small page-local build-time data,
- materialize remote content into Markdown or data files before running `mix astral.build`.

Live content collections and request-time data freshness belong with future hybrid/runtime modes. See the backend, authentication, and testing guide for the current CMS/backend-service boundary.