Astral uses file-based routes for pages and generated routes for non-page outputs. Use dynamic .astral pages when one template should produce several HTML pages, top-level get declarations for site-specific static outputs, and plugins for reusable route generators.
File-based page routes
Files under pages/ become routes automatically:
pages/index.md -> /
pages/about.md -> /about/
pages/about/index.astral -> /about/
pages/docs/intro.html -> /docs/intro/
pages/404.astral -> /404/ and dist/404.htmlUse ordinary <a> elements for navigation:
<a href="/about/">About</a>See the navigation guide for current i18n, prefetch, and view-transition boundaries.
Dynamic filenames use Astro-style brackets at the file boundary and Plug/Phoenix-style route params internally:
pages/blog/[slug].astral -> /blog/:slug
pages/docs/[...path].md -> /docs/*pathRendered params are available as atom-keyed @params:
<h1>{@params.slug}</h1>Collection-backed dynamic pages
If a dynamic page matches collection entry routes, Astral renders one page per matching entry and assigns @entry:
content/posts/hello.md -> /blog/hello/
pages/blog/[slug].astral -> /blog/:slug<article data-slug={@params.slug}>
<h1>{@entry.data.title}</h1>
{@entry.content}
</article>@entry.content implements Phoenix's HTML-safe protocol. Use a collection schema for fields you read from @entry.data; raw frontmatter remains available as string-keyed @entry.metadata.
Setup-declared dynamic .astral pages
A dynamic page filename such as pages/tags/[tag].astral declares a route pattern. In the setup block, assign paths to a list of route path contracts with path/1:
---
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}>{post.data.title}</li>
</ul>The setup paths list is evaluated during discovery. Each path/1 item carries atom-keyed route params plus optional atom-keyed page assigns. Astral generates concrete routes such as /tags/elixir/ and makes rendered params available as atom-keyed @params.
Static endpoints with generated routes
For static data files and endpoint-like outputs, declare one-off generated routes directly in astral.config.exs:
get "/robots.txt", content_type: "text/plain" do
"User-agent: *\nAllow: /\n"
end
get "/search-index.json", content_type: "application/json" do
site
|> MySite.Search.index()
|> Jason.encode!()
end
get "/social-image.png", content_type: "image/png" do
MySite.SocialImage.render_png!(site)
endThe block runs in dev for matching requests and during static builds when Astral writes the output file. The block can use site, route, config, and assigns.
Generated routes are static-build endpoints: they produce files such as /search-index.json, /feed.xml, /sitemap.xml, /robots.txt, or generated images. Astral does not yet provide live server API routes for POST, PUT, DELETE, or request-body handling; those belong to a future runtime/hybrid adapter.
Use plug declarations for Plug-compatible middleware around generated responses:
plug MySite.GeneratedRouteHeaders, cache: "public, max-age=3600"
get "/data.json", content_type: "application/json" do
Jason.encode!(%{ok: true})
endThis plug support is intentionally scoped to config-generated routes. It is not full page middleware: it does not run around every page render and does not provide per-request locals for ordinary static pages.
Collection pagination plugin
plugin Astral.Plugin.CollectionPages,
collection: :posts,
pattern: "/blog/*page",
page_size: 10,
layout: "blog.html"The *page route parameter omits page one:
/blog/
/blog/2/
/blog/3/The pagination layout receives @page, @collection, @site, @collections, @routes, and @route. Declare a collection schema for any fields you read through entry.data, such as entry.data.title below.
<h1>Blog</h1>
<%= for entry <- @page.entries do %>
<article>
<h2><a href="<%= entry.route_path %>"><%= entry.data.title %></a></h2>
</article>
<% end %>
<nav>
<%= if @page.urls.previous do %>
<a href="<%= @page.urls.previous %>">Previous</a>
<% end %>
<%= if @page.urls.next do %>
<a href="<%= @page.urls.next %>">Next</a>
<% end %>
</nav>Lower-level pagination helpers
Use Astral.Pagination directly for custom generated indexes:
entries
|> Astral.Pagination.pages(pattern: "/blog/*page", page_size: 10)
|> Astral.Pagination.routes(site.config, assigns: %{collection: :posts})Route patterns
Astral route patterns use Plug/Phoenix-style segments:
/blog/:slug
/blog/*page
/tags/:tag/*pageUse generated routes when a page is not backed by a single file in pages/.
Output precedence
Astral writes static output in deterministic layers:
public files < pages < generated routesIf a generated route writes the same output path as a page or public file, the generated route wins. Prefer unique output paths unless the override is intentional.
Astral reports duplicate page routes. Broader output-conflict diagnostics for public files and generated routes are planned.
Redirects, rewrites, i18n, and middleware scope
Astral does not yet have core redirect or rewrite rules. For static redirects today, use your host's redirect configuration or generate host-specific files with get routes or plugins.
Astral also does not yet have first-class i18n routing middleware. Use localized folders, collection locale fields, and site-owned link helpers for static multilingual sites today. Locale fallbacks, domain-based locales, browser-language detection, and route verification belong with future runtime/hybrid routing work.
Full page middleware is not implemented yet. Current middleware-like support is limited to plug declarations around config-generated routes. Use the render_page plugin callback for build-time HTML transforms across rendered pages. See the server runtime guide for the current on-demand rendering, action, session, and route caching boundaries.