Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Blume can’t enumerate the paths a dynamic [param] route serves, so those pages are left out of the sitemap, get no generated Open Graph card, and are unknown to blume validate, which reports a link to one as broken. Pass ogImage to PageLayout for their share image, or give each path its own static file (pages/compare/mintlify.astro rather than pages/compare/[tool].astro) to get all three.

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:

PropType
configBlumeDataConfig

Resolved site settings: title, description, logo, favicon, appleIcon, banner, theme, site, repoUrl, github (owner, repo, host, and the REST api base — null when unset), search, i18n, mcp, assistant, og, analytics, feedback, structuredData, toc, codeThemes, codeWrap, and imageZoom.

TypeBlumeDataConfig
navigationNavigation

The sidebar, tabs, and selectors inferred from your content (default locale).

TypeNavigation
navigationByLocaleRecord<string, Navigation>

Per-locale navigation trees, keyed by locale code. Empty unless i18n is configured.

TypeRecord<string, Navigation>
routesBlumeRoute[]

Every content page: { id, path, title, locale, indexable, hidden, draft, fallback, editUrl, lastModified, alternates, collection, entryId }.

TypeBlumeRoute[]
feedsBlumeFeed[]

Generated RSS feeds: { href, title }.

TypeBlumeFeed[]
fontCssVarsFontHead[]

The configured fonts for Astro's <Font> component in the head, one per family: { cssVariable, preloadWeights, preloadSubsets? }.

TypeFontHead[]
uiUIStrings

Resolved UI chrome strings for the default locale (search, sidebar, and footer labels).

TypeUIStrings
uiByLocaleRecord<string, UIStrings>

Per-locale UI strings, keyed by locale code. Empty unless i18n is configured.

TypeRecord<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";
import { getBlumeCollection } from "blume/runtime";

// Under i18n every locale serves a route for each entry (its translation, or
// a copy of the untranslated page), all sharing the entry's id. Key the map on
// the default locale's routes only, so each post links once, in one language.
const routes = getBlumeCollection(data, {
  locale: data.config.i18n?.defaultLocale,
});
const routeByEntry = new Map(routes.map((route) => [route.entryId, route.path]));

const posts = (await getCollection("docs"))
  .filter((entry) => entry.data.type === "blog" && routeByEntry.has(entry.id))
  .map((entry) => ({
    description: entry.data.description,
    href: routeByEntry.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, hidden pages, and i18n fallback copies 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>

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, and — when the assistant is configured — its trigger. None of it needs wiring up per page. Pass assistantEnabled={false} to leave the assistant trigger off one page while keeping it everywhere else.

The language switcher is the exception. A custom page is served only at its own route, so Blume can’t know which other locales have a version of it, and the header shows no switcher unless you pass one: localeSwitch takes an entry per locale — { code, label, dir, href, current, untranslated } — pointing at the page you built for each language.

The agent-discovery head links come along too: the layout reads the resolved config, so a custom page carries the same describedby, ai-catalog, and ard links the docs pages do without passing a prop. The homepage also advertises its /index.md Markdown mirror as a text/markdown alternate, since that mirror always exists; other custom pages have none, so none is advertised. Pass discovery={null} to drop the links from one page.

Pass transparentHeader to start the header see-through with its chrome in white, so it can sit over a dark hero at the top of the page; it becomes the usual frosted bar as soon as the page scrolls, and the search dialog keeps the page’s own colors throughout. The hero has to run under the header for this to show — pull it up by the header’s height (-mt-16) and pad its top to compensate.

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 (not a dynamic [param] one) — the home included, the most-shared URL — served at /og/<route>.png (/og/index.png for /). The home card’s headline is the site title, with the site description as the subtitle beneath it; 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"
  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 graph the docs pages carry, with a WebPage node on the home page — so the home page (usually a custom page) isn’t the one URL without structured data. A custom page has no content file to date, so pass lastModified (a date string or Date) to emit its dateModified; without a machine-readable date, search engines can take any other date the page shows as the page’s own. Pass structuredDataEnabled={config.structuredData} to keep it in sync with the structuredData 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:

---
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. Under the message, a Where to look next list links every top-level section plus the sitemap.xml and llms.txt indexes when they exist, so a reader — or an agent that followed a stale URL — has a way back.

The page also has a Markdown twin at /404.md and a JSON twin at /404.json (RFC 9457 problem details) with the same recovery links (absolute URLs once deployment.site is set), plus the openapi.json description when the JSON API is on. On a Vercel or Cloudflare server build, a request for a missing page that sends Accept: text/markdown gets the Markdown body with the 404 status instead of the HTML shell, and one that sends Accept: application/json gets the problem document — so an agent never has to parse a page of chrome to learn where to go next. On Vercel the same goes for a .md or .json URL no file backs.

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, along with its /404.md and /404.json twins. 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.

Last updated on

Was this page helpful?