---
title: Custom Pages
description: Mount fully custom .astro routes alongside your docs and read your site's config, navigation, and content from the blume:data module.
---

Most of a Blume site is Markdown, but sometimes you need a route that _isn't_ a doc — a landing page, a pricing page, a hand-built blog or changelog index, or an interactive dashboard. Drop an `.astro` file under your **pages** folder and Blume mounts it as a real route, right alongside your content.

## Add a page

Create a `pages/` folder at your project root and add an `.astro` file:

```astro pages/pricing.astro lineNumbers
---
import data from "blume:data";
---

<h1>Pricing for {data.config.title}</h1>
```

`blume dev` picks it up immediately and `blume build` prerenders it to static HTML. The folder name is configurable with [`content.pages`](/docs/configuration#content) (default `"pages"`).

Custom pages keep their original location on disk, so relative imports, component imports, and [`getStaticPaths`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths) all work exactly as they do in a plain Astro project — Blume mounts each file where it sits rather than copying it.

## Files and routes

Each file's path under the pages folder becomes its route. `index` maps to the parent folder, and dynamic `[param]` segments are preserved:

| File                      | Route         |
| ------------------------- | ------------- |
| `pages/pricing.astro`     | `/pricing`    |
| `pages/blog/index.astro`  | `/blog`       |
| `pages/blog/[slug].astro` | `/blog/:slug` |
| `pages/changelog.astro`   | `/changelog`  |

A custom page **wins** over a generated route at the same path. Adding `pages/changelog.astro`, for example, replaces Blume's [generated changelog timeline](/docs/advanced/changelog) with your own.

## Reading site data

Import `blume:data` to read the same resolved config, navigation, routes, and feeds the rest of the site uses:

```astro pages/all-pages.astro lineNumbers
---
import data from "blume:data";
---

<h1>All pages</h1>
<ul>
  {
    data.routes
      .filter((route) => route.indexable)
      .map((route) => (
        <li>
          <a href={route.path}>{route.title}</a>
        </li>
      ))
  }
</ul>
```

Inside a Blume project the module is typed automatically. You can also pull the shape in explicitly — for typed helpers, props, or your own tsconfig — with `import type { BlumeData } from "blume"`:

```ts
import type { BlumeData, BlumeRoute } from "blume";

const indexable = (data: BlumeData): BlumeRoute[] =>
  data.routes.filter((route) => route.indexable);
```

The module exposes:

<TypeTable
  type={{
    config: {
      type: "BlumeDataConfig",
      required: true,
      description:
        "Resolved site settings: title, description, logo, favicon, appleIcon, banner, theme, site, repoUrl, search, i18n, mcp, ask, og, analytics, feedback, structuredData, toc, codeThemes, codeWrap, and imageZoom.",
    },
    navigation: {
      type: "Navigation",
      required: true,
      description:
        "The sidebar, tabs, and selectors inferred from your content (default locale).",
    },
    navigationByLocale: {
      type: "Record<string, Navigation>",
      required: true,
      description:
        "Per-locale navigation trees, keyed by locale code. Empty unless i18n is configured.",
    },
    routes: {
      type: "BlumeRoute[]",
      required: true,
      description:
        "Every content page: { id, path, title, locale, indexable, hidden, draft, fallback, editUrl, lastModified, alternates, collection, entryId }.",
    },
    feeds: {
      type: "BlumeFeed[]",
      required: true,
      description: "Generated RSS feeds: { href, title }.",
    },
    fontCssVars: {
      type: "string[]",
      required: true,
      description:
        "CSS variable names for the configured fonts (Astro <Font> integration).",
    },
    ui: {
      type: "UIStrings",
      required: true,
      description:
        "Resolved UI chrome strings for the default locale (search, sidebar, and footer labels).",
    },
    uiByLocale: {
      type: "Record<string, UIStrings>",
      required: true,
      description:
        "Per-locale UI strings, keyed by locale code. Empty unless i18n is configured.",
    },
  }}
/>

`routes` carries page metadata but not frontmatter like `type` or `date`. To build a list filtered by content type — a blog or changelog index — pair it with Astro's `docs` content collection, which holds the frontmatter:

```astro pages/blog/index.astro lineNumbers
---
import { getCollection } from "astro:content";
import data from "blume:data";

// Each route's id matches its collection entry id.
const routeById = new Map(data.routes.map((route) => [route.id, route.path]));

const posts = (await getCollection("docs"))
  .filter((entry) => entry.data.type === "blog" && !entry.data.draft)
  .map((entry) => ({
    description: entry.data.description,
    href: routeById.get(entry.id),
    title: entry.data.title,
  }));
---

<ul>
  {
    posts.map((post) => (
      <li>
        <a href={post.href}>{post.title}</a>
        <p>{post.description}</p>
      </li>
    ))
  }
</ul>
```

## Runtime helpers

`blume/runtime` bundles the common data patterns so you don't reach into `blume:data` internals.

**`getBlumeCollection(data, query?)`** selects content routes — filtered by collection, locale, or path prefix, with drafts and hidden pages excluded and the result sorted by path — which is exactly what a custom index needs:

```astro pages/blog/index.astro lineNumbers
---
import data from "blume:data";
import { getBlumeCollection } from "blume/runtime";

const posts = getBlumeCollection(data, { prefix: "/blog" });
---

<ul>
  {posts.map((post) => (
    <li><a href={post.path}>{post.title}</a></li>
  ))}
</ul>
```

**`<BlumePage>`** renders a content entry's body inside a custom page, with Blume's built-in MDX components (callouts, cards, steps…) already wired in — for featuring a doc on a landing page or building a bespoke index that shows real content:

```astro pages/index.astro lineNumbers
---
import BlumePage from "blume/components/BlumePage.astro";
import data from "blume:data";
import { getBlumeCollection } from "blume/runtime";

const [intro] = getBlumeCollection(data, { prefix: "/docs" });
---

{intro && <BlumePage id={intro.entryId} />}
```

Pass `components` to add your own overrides or islands (which live in the generated runtime and aren't imported by default), and `collection` to read from a collection other than `"docs"`.

## Using the site layout

`RootLayout` gives a custom page the full docs chrome — header, sidebar, search, TOC, and theme — by wrapping it in the same 3-column grid the generated pages use. For a landing or marketing page that grid is in the way, so reach for **`PageLayout`** instead: it provides the document shell, header, theme, and fonts, then a single full-width `<slot />` (no sidebar, no prose, no TOC). An optional `footer` slot renders after `<main>`:

```astro pages/index.astro lineNumbers
---
import PageLayout from "blume/components/layout/PageLayout.astro";
import data from "blume:data";
import Footer from "./_home/Footer.astro";

const { config } = data;
---

<PageLayout
  site={{ title: config.title, description: config.description }}
  logo={config.logo}
  banner={config.banner}
  analytics={config.analytics}
  navigation={data.navigation}
  favicon={config.favicon}
  fontCssVars={data.fontCssVars}
  themeMode={config.theme.mode}
  searchEnabled={config.search.enabled}
  siteUrl={config.site}
  ogEnabled={config.og.enabled}
  page={{ title: "Acme — the fastest docs", description: config.description }}
>
  <section class="mx-auto max-w-5xl px-6 py-24">
    <h1>Build docs that fly</h1>
  </section>
  <Footer slot="footer" />
</PageLayout>
```

The header a custom page gets is the same one the docs pages get, so the chrome that lives in it comes along: search, the theme toggle, the language switcher, and — when [Ask AI](/docs/configuration/ai) is configured — the Ask AI trigger. None of it needs wiring up per page. Pass `askEnabled={false}` to leave the Ask trigger off one page while keeping it everywhere else.

Passing `siteUrl` (and `ogEnabled`) derives the page's `canonical` and a generated `og:image` automatically: Blume renders an Open Graph card for every static custom page — the home included, the most-shared URL — served at `/og/<route>.png` (`/og/index.png` for `/`). The home card uses the site title with the description as its eyebrow; a deeper page is titled from its last path segment. Set `ogImage` or `canonical` explicitly to override either. `ogImage` takes a root-relative path — a file in `public/`, resolved against [`deployment.site`](/docs/deployment) to the absolute URL crawlers need — or an external URL, which passes through untouched:

```astro pages/index.astro lineNumbers
<PageLayout
  siteUrl={config.site}
  ogEnabled={config.og.enabled}
  ogImage="/opengraph-image.png"
  ogImageAlt="Acme — the fastest docs"
  ogImageSize={{ width: 1200, height: 630 }}
  page={{ title: config.title }}
>
  <!-- page content -->
</PageLayout>
```

Only this page changes — every other route keeps its generated card — so it's how you give the home page alone a bespoke share image. The generated card declares its size and alt text to crawlers on its own; for your own `ogImage`, pass `ogImageAlt` and `ogImageSize` alongside so the share card gets the same treatment.

The page also emits schema.org JSON-LD — the same `WebSite` graph the docs pages carry, so the home page (usually a custom page) isn't the one URL without structured data. Pass `structuredDataEnabled={config.structuredData}` to keep it in sync with the [`structuredData`](/docs/configuration/seo) config, or `structuredDataEnabled={false}` to turn it off for one page.

`page.title` is used verbatim as the document title (no `- siteTitle` suffix), since marketing pages usually set their own. To give a custom page the full docs chrome instead — sidebar, TOC, and all — wrap it in `RootLayout`, the layout the generated pages use. Pull the required props straight from `blume:data`:

```astro pages/pricing.astro lineNumbers
---
import RootLayout from "blume/components/layout/RootLayout.astro";
import data from "blume:data";
---

<RootLayout
  site={{ title: data.config.title, description: data.config.description }}
  logo={data.config.logo}
  banner={data.config.banner}
  navigation={data.navigation}
  page={{ title: "Pricing", route: "/pricing" }}
  headings={[]}
  themeMode={data.config.theme.mode}
  searchEnabled={data.config.search.enabled}
  indexable={true}
>
  <h1>Pricing</h1>
</RootLayout>
```

:::note
`RootLayout` is part of the generated runtime, so its props can change between releases. When you want a layout that's fully yours, [`blume eject`](/docs/configuration/customization#eject) turns `.blume/` into a standard Astro project you own outright.
:::

## 404 page

Blume ships a default **not found** page out of the box: a centered "404" message wrapped in the site chrome (header, search, theme), served for any unmatched URL. `blume build` writes it to `404.html`, which static hosts serve automatically, and `blume dev` shows it for unknown routes.

To replace it with your own, add a `pages/404.astro`. It owns the `/404` route the same way `pages/changelog.astro` takes over the changelog — your page wins and the default is dropped. Build it like any other custom page, in `PageLayout` or `RootLayout`:

```astro pages/404.astro lineNumbers
---
import PageLayout from "blume/components/layout/PageLayout.astro";
import data from "blume:data";
---

<PageLayout
  site={{ title: data.config.title, description: data.config.description }}
  logo={data.config.logo}
  navigation={data.navigation}
  themeMode={data.config.theme.mode}
  searchEnabled={data.config.search.enabled}
  page={{ title: "Page not found", route: "/404" }}
  noindex={true}
>
  <section class="mx-auto max-w-2xl px-6 py-24 text-center">
    <h1>This page took a wrong turn</h1>
    <a href="/">Back to home</a>
  </section>
</PageLayout>
```

To keep the default design but change its wording — including for other languages — override the `notFound` [UI strings](/docs/content/i18n) (`title`, `description`, `home`) via `i18n.ui`.

## Interactive pages

Custom pages are ordinary Astro, so you can drop in React (or any framework) [islands](/docs/configuration/customization#interactive-islands) with a hydration directive. React switches on automatically the moment your project contains a `.tsx` or `.jsx` file.

<CardGroup cols={2}>
  <Card
    title="Customization"
    href="/docs/configuration/customization"
    icon="file"
  >
    Component overrides, React islands, the registry, and eject.
  </Card>
  <Card title="Blog" href="/docs/advanced/blog" icon="book-open">
    Author posts and build a custom blog index.
  </Card>
</CardGroup>
