Skip to main content

guides/features/pagination-and-routes.md

# Routing, Pagination, and Generated Routes

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:

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

Use ordinary `<a>` elements for navigation:

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

```text
pages/blog/[slug].astral   -> /blog/:slug
pages/docs/[...path].md    -> /docs/*path
```

Rendered params are available as atom-keyed `@params`:

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

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

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

```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}>{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`:

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

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

```elixir
plug MySite.GeneratedRouteHeaders, cache: "public, max-age=3600"

get "/data.json", content_type: "application/json" do
  Jason.encode!(%{ok: true})
end
```

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

```elixir
plugin Astral.Plugin.CollectionPages,
  collection: :posts,
  pattern: "/blog/*page",
  page_size: 10,
  layout: "blog.html"
```

The `*page` route parameter omits page one:

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

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

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

```text
/blog/:slug
/blog/*page
/tags/:tag/*page
```

Use generated routes when a page is not backed by a single file in `pages/`.

## Output precedence

Astral writes static output in deterministic layers:

```text
public files < pages < generated routes
```

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