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:
pages/index.md -> /
pages/about.md -> /about/
pages/blog/post.md -> /blog/post/Use YAML frontmatter for page metadata and route options:
---
title: About
layout: default.html
permalink: /about-us/
---
# AboutAstral uses MDEx for Markdown and YamlElixir for frontmatter. TOML frontmatter is not supported today.
Markdown options and syntax highlighting
Pass MDEx options through the site configuration:
site do
markdown extension: [strikethrough: true, table: true]
endThe same options apply during discovery and final rendering of Markdown pages and collection entries, including content used by feeds. Astral retains its YAML frontmatter delimiter and heading ID convention, and enables HEEx in the final page rendering pass. Existing sites keep their current defaults.
For server-rendered syntax highlighting, add Lumis and a parser package for each language your code fences use to your site's Mix dependencies:
{:lumis, "~> 0.10"},
{:lumis_wasm_elixir, "~> 0.26"},
{:lumis_wasm_bundle_web, "~> 0.1"}Then select the native highlighter in config/config.exs:
config :mdex_native, syntax_highlighter: :lumisThen configure Lumis through MDEx in astral.config.exs:
markdown syntax_highlight: [
engine: :lumis,
opts: [
formatter: {:html_multi_themes,
themes: [light: "github_light", dark: "github_dark"],
default_theme: "light-dark()"}
]
]Use language-tagged code fences such as elixir or sql. The generated HTML
needs no browser highlighter. The dual-theme formatter follows CSS color-scheme;
set it to light dark for the system preference or explicitly to light/dark
for a site toggle. A fence in a language without an installed parser renders as
plain text; the language catalog lists
every package, and bundles such as lumis_wasm_bundle_web install a set at once.
If MDEx Native was already compiled with another highlighter, run
mix deps.clean mdex_native --build before rebuilding (also in MIX_ENV=test
when running tests).
Markdown plugins
MDEx plugins go in the same options, in any of the forms MDEx accepts:
markdown plugins: [MDExGFM, {MDExMermaid, mermaid_version: "11"}]A plugin is a module with attach/2 that adds steps to the parsed document, so a
site can write its own in lib/. This one wraps every blockquote in a
<div class="quote">:
defmodule MySite.Markdown.Quotes do
alias MDEx.Document
def attach(document, _options \\ []) do
Document.append_steps(document, wrap_quotes: &wrap_quotes/1)
end
defp wrap_quotes(document) do
Document.update_nodes(document, MDEx.BlockQuote, fn quote ->
%MDEx.BlockDirective{info: "quote", nodes: [quote]}
end)
end
endPlugins run on pages and collection entries while MDEx parses them, so they see
Markdown images before Astral turns local ones into <.image> components, and
the headings Astral collects for a table of contents include their changes.
Components in Markdown
Markdown pages and collection entries can use local .astral components through HEEx syntax:
# 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:
<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:
<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:
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
endEach 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:
---
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:
content/posts/hello.md -> /blog/hello/
pages/blog/[slug].astral -> /blog/:slugFor arbitrary derived routes such as tag pages, use setup-declared dynamic .astral paths:
---
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:
get "/products.json", content_type: "application/json" do
products = Req.get!("https://api.example.com/products").body
Jason.encode!(products)
endExample page setup data:
---
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
getroute for static JSON, feeds, indexes, or generated images, - fetch in
.astralsetup 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.