Skip to main content

guides/features/pages-and-layouts.md

# Pages and Layouts

Astral discovers pages from the configured `pages/` directory and renders them into static HTML routes.

## Markdown pages

Markdown pages use MDEx and YAML frontmatter:

```markdown
---
title: About
layout: default.html
---

# About
```

Routes are derived from file paths:

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

Set `permalink` to override the route:

```markdown
---
title: About
permalink: /about-us/
---
```

Set `layout: false` to render a page without a layout.

## Components in Markdown

Markdown pages and collection entries can use local `.astral` components through MDEx's HEEx integration:

```md
# Project

<.callout>
  This block is rendered by `components/callout.astral`.
</.callout>
```

Components are discovered from the configured `components/` directory, the same as `.astral` pages and layouts. Use HEEx local component syntax (`<.name>`) rather than MDX imports.

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

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

Prefer static Markdown heading text. Heading ids are generated by the Markdown parser before HEEx expressions are evaluated. See the Markdown, content, and data guide for the current boundary around MDX, Markdown imports, and remote Markdown.

## HTML pages

Plain `.html` files in `pages/` are supported for pages that do not need Markdown processing.

## Custom 404 page

Create a root `404` page to customize the page that static hosts use for missing routes:

```text
pages/404.md
pages/404.html
pages/404.astral
```

Astral writes root 404 pages to `dist/404.html`, even though the route still matches `/404/` like any other page. This mirrors the static-host convention used by Astro and common deployment providers.

In development, visiting `/404` or `/404/` renders the custom page with HTTP status `404`.

Nested `404` pages are ordinary pages for now; only the root `pages/404.*` file gets the `404.html` output convention.

## Dynamic file routes

Use bracket segments for collection-backed dynamic pages:

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

Bracket filenames are portable file-route sugar. Astral converts them to Elixir/Phoenix-style route patterns internally.

Dynamic page templates render once for each collection entry whose route matches the file route pattern. Route params are exposed as atom-keyed `@params` assigns. Use a collection schema for fields read from `@entry.data`:

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

```eex
<p>Path: <%= @params.path %></p>
```

A dynamic file route overrides the default collection entry page for the same route, letting the page template own the final HTML.

## Layouts

Layouts live in the configured layouts directory. EEx layouts use `@content` where the page HTML should be inserted:

```html
<!doctype html>
<html lang="en">
  <head>
    <title><%= @page.title || "My Site" %></title>
  </head>
  <body>
    <main data-route="<%= @route %>">
      <%= @content %>
    </main>
  </body>
</html>
```

Common assigns:

- `@content` — rendered page HTML.
- `@page` — current `%Astral.Content{}`.
- `@metadata` — decoded frontmatter map.
- `@route` — route path such as `/about/`.
- `@params` — atom-keyed route params for dynamic file routes.
- `@site` — discovered `%Astral.Site{}`.
- `@collections` — content entries grouped by collection name.
- `@entry` — current collection entry, otherwise `nil`.
- `@routes` — generated routes.

## Head metadata

Astral does not provide a built-in SEO metadata component. Use ordinary HTML in layouts, or create a local `.astral` component for shared head tags. The `base_head` name mirrors Astro's common `BaseHead.astro` pattern while avoiding a confusing `<head><.head /></head>` shape:

```astral
---
title = assigns[:title] || "My Site"
description = assigns[:description] || "Site description"
canonical = "#{String.trim_trailing(assigns[:site_url], "/")}#{assigns[:route]}"

assigns =
  assigns
  |> assign(:title, title)
  |> assign(:description, description)
  |> assign(:canonical, canonical)
---

<link rel="canonical" href={@canonical} />
<title>{@title}</title>
<meta name="description" content={@description} />
<meta property="og:title" content={@title} />
<meta property="og:description" content={@description} />
```

Then call it from a `.astral` layout:

```astral
<head>
  <.base_head
    title={@page.title || "My Site"}
    description={@metadata["description"]}
    route={@route}
    site_url="https://example.com"
  />
</head>
```

This keeps metadata as regular HTML instead of introducing a framework-level metadata API.

## Heading anchors

Markdown headings get stable `id` attributes. Heading metadata is available as `@page.headings` for table-of-contents layouts:

```eex
<nav>
  <%= for heading <- @page.headings do %>
    <a href="#<%= heading.id %>"><%= heading.text %></a>
  <% end %>
</nav>
```