Fetching & routes
How a Next host loads and routes documents.
The Next.js app owns the URLs and the UI. Backmatter owns the documents: a type and a slug, each pointing at a file. Querying is how a page loads that file.
@backmatter/next fetches those documents. A [slug] segment is the file slug, whether the file is read from content/ on disk or from the published content API.
app/[...backmatter]/page.tsx (scaffolded) or app/blog/[slug]/page.tsx (yours)
│
▼
cms ← .backmatter/index.ts (generated from models/*.ts + backmatter.config.ts)
│
├─ FS mode → content/blog/{slug}.mdx (@backmatter/core path match)
└─ API mode → published documents from Backmatter
│
▼
{ frontmatter, body } → renderDoc / MdxBody → site CSS + blocks/*.tsx
The public site does not read GitHub. The catch-all mounts every model at its route, or you write the route and cms fills the data. Both use the same generated client.
Core building blocks
| Piece | Role |
|---|---|
defineModel / path + route templates | Where files live; how the slug binds; which URL the catch-all serves |
.backmatter/index.ts → cms | Generated host client: listDocs / getDoc / query, typed from models/*.ts |
createCatchAll(cms) | Page + generateStaticParams for app/[...backmatter]/page.tsx |
| FS loader | Scan/read content/ using model paths |
| API loader | The same documents, published, with the content token |
| Preview helpers | Client helpers for unsaved edits on your preview page (live preview) |
@backmatter/core does not fetch HTTP or render React. It defines identity, paths, parse, validate. @backmatter/next is the Next-facing fetch layer.
Catch-all (zero-code pages)
backmatter init writes one file:
// app/[[...backmatter]]/page.tsx (Next 16)
import { createCatchAll } from "@backmatter/next/routes";
import { cms } from "backmatter";
import { components } from "backmatter/mdx";
import { blockLoaders } from "backmatter/blocks";
import { blockFallbacks } from "backmatter/block-fallbacks";
import { layouts } from "backmatter/layouts";
import { sources } from "backmatter/sources";
const route = createCatchAll(cms, {
components,
blocks: blockLoaders,
fallbacks: blockFallbacks,
layouts,
sources,
});
export const generateStaticParams = route.generateStaticParams;
export const generateMetadata = route.generateMetadata;
export default route.Page;
instrumentation.ts (project root or src/ when using src/app) imports backmatter/sync inside if (process.env.NEXT_RUNTIME === "nodejs"). The catch-all does not import block modules.
backmatter init also writes proxy.ts (Next 16; the old middleware.ts name is deprecated). The handler is export { proxy } from "@backmatter/next/proxy". Next requires export const config with the matcher to live in that file, not be re-exported.
Each model declares a route. route: "/blog/{slug}" serves the blog file at /blog/hello. A model with external: true serves a URL whose record comes from outside the repo, such as a product in a catalog. External sources is that path. Set route: false for data and globals so the catch-all ignores them. matchRoute tries the most specific template first. render(doc, body) wraps the body in your layout. Anything the catch-all does not match falls through to your own routes or notFound().
Dynamic slug routes (hand-written)
Index + detail when you want full control of a URL:
import { cms } from "backmatter";
const posts = await cms.query.blog.findMany();
// → links to /blog/{slug}
// app/blog/[slug]/page.tsx
const { slug } = await params;
const doc = await cms.query.blog.get(slug);
if (!doc) notFound();
return renderDoc(doc, { components: blocks });
The [slug] param is the document slug in that type. get, findMany, where, and include are Querying.
Static vs dynamic rendering
generateStaticParams—listDocsat build time → pre-render known slugs (great for marketing sites).force-dynamic/ ISR — fetch per request or revalidate on commit (CMS-driven sites).
Both use the same getDoc / listDocs. Backmatter does not force one Next caching mode.
Dual source (FS vs API)
| Mode | When | Source |
|---|---|---|
| Filesystem | Local next dev without tokens | content/ on disk via model paths |
| API | Staging and production | Published documents, with the content token |
Same route code. Switch with BACKMATTER_API_URL and BACKMATTER_CONTENT_TOKEN. Locally, with no token, the site reads the files in the repo. In production it reads the published documents. Editing in the CMS and editing the file in git stay the same pages.
Rendering
| You | Backmatter |
|---|---|
| Layout, CSS, SEO | Loads the frontmatter and body |
Components in blocks/ | Compiles the body with renderDoc or MdxBody |
renderDoc / MdxBody compile the body string with GFM. No default tag styles — wrap the result in site CSS. Pass components only when the doc uses custom MDX blocks.
Preview
The catch-all serves the public URL. With BACKMATTER_PREVIEW_TOKEN set, that same URL shows the editor's unsaved copy of the open document. Visitors still get the published file.
A page you wrote yourself uses @backmatter/next/preview (fetchPreviewDoc and subscribePreview) and the preview token. The content token stays on published reads. Live preview is that path. The dev bar puts the CMS editor on the page you are browsing.