Custom Pages
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:
---
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 (default "pages").
Custom pages keep their original location on disk, so relative imports, component imports, and 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 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:
---
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":
import type { BlumeData, BlumeRoute } from "blume";
const indexable = (data: BlumeData): BlumeRoute[] =>
data.routes.filter((route) => route.indexable);
The module exposes:
configBlumeDataConfig
Resolved site settings: title, description, logo, favicon, appleIcon, banner, theme, site, repoUrl, search, i18n, mcp, og, analytics, feedback, structuredData, codeWrap, and imageZoom.
BlumeDataConfignavigationNavigation
The sidebar, tabs, and selectors inferred from your content (default locale).
NavigationnavigationByLocaleRecord<string, Navigation>
Per-locale navigation trees, keyed by locale code. Empty unless i18n is configured.
Record<string, Navigation>routesBlumeRoute[]
Every content page: { id, path, title, locale, indexable, hidden, draft, fallback, editUrl, lastModified, alternates, collection, entryId }.
BlumeRoute[]feedsBlumeFeed[]
Generated RSS feeds: { href, title }.
BlumeFeed[]fontCssVarsstring[]
CSS variable names for the configured fonts (Astro <Font> integration).
string[]uiUIStrings
Resolved UI chrome strings for the default locale (search, sidebar, and footer labels).
UIStringsuiByLocaleRecord<string, UIStrings>
Per-locale UI strings, keyed by locale code. Empty unless i18n is configured.
Record<string, UIStrings>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:
---
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:
---
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:
---
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>:
---
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>
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 to the absolute URL crawlers need — or an external URL, which passes through untouched:
<PageLayout
siteUrl={config.site}
ogEnabled={config.og.enabled}
ogImage="/opengraph-image.png"
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.
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:
---
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>
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:
---
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 (title, description, home) via i18n.ui.
Interactive pages
Custom pages are ordinary Astro, so you can drop in React (or any framework) islands with a hydration directive. React switches on automatically the moment your project contains a .tsx or .jsx file.