External sources
Serve a catalog record and a CMS page on the same URL.
A product URL exists because the catalog has that product. Price, images, and stock stay in the catalog. The CMS still owns the page: which blocks appear, in what order, and what copy sits around the record.
external: true on the model marks that split. A file in sources/ loads the record for the URL. Editors author one template document, and every record that has no file of its own renders through it. A file for one slug is an override for that record.
What you add
| File | Role |
|---|---|
models/product.ts | external: true, a route with {slug}, and a blocks field. |
sources/product.ts | defineSource. load returns the catalog record for a slug. |
content/product/_template.json | The page every product renders, until one product has its own file. |
blocks/ProductOverview.tsx | A block. The catalog record arrives as props.data. |
backmatter generate writes .backmatter/sources.ts and the catch-all passes it through. A hand-written catch-all imports sources from backmatter/sources and passes it to createCatchAll.
How a URL resolves
/products/oak-chair matches the product route as slug oak-chair. Then:
load({ slug })fetches the catalog record.nullis a 404.- The CMS document for
oak-chairis used when that file exists. - Otherwise the
_templatedocument is used. - The page renders that document with the catalog record.
A type without external: true skips this. Its blocks receive no record.
import { defineModel, field } from "@backmatter/next";
export default defineModel({
type: "product",
format: "page",
route: "/products/{slug}",
external: true,
fields: {
title: field.text(),
blocks: field.blocks(),
},
});
external: true sets the template slug to _template. Set template to use another slug. template on its own is an error, and external cannot be combined with singleton or indexSlug.
The default path for a page model is content/product/{slug}.json, so the template file is content/product/_template.json. Use a longer path when files live under another folder, such as content/en/product/{slug}.json.
The source
The file name is the model type. The default export is defineSource.
import { defineSource } from "@backmatter/next";
import { getProduct, listProductSlugs, type Product } from "../lib/catalog";
export default defineSource<Product>({
load: ({ slug }) => getProduct(slug),
list: () => listProductSlugs(),
sample: () => getProduct("oak-chair"),
metadata: (product) => ({ title: product.title }),
});
| Method | When it runs |
|---|---|
load | Every real slug. Return null when the catalog has no such record. Required. |
list | Build time. Slugs here are prerendered, including products with no CMS file. Omit it to prerender only slugs that have a document. |
sample | Preview of the template document, so the blocks are not empty. Omit it and that preview gets data: null. |
metadata | The page title when the document's title field is empty. |
load receives { slug }. An external model with no sources/{type}.ts throws on the first request, and the message names the missing file.
The published page caches the record with the page. A catalog change appears when that page revalidates.
The template document
Create a product document with the slug _template and build the page there: the blocks, their order, and the copy that every product shares. /products/oak-chair renders that document when content/…/product/oak-chair.json does not exist, with the oak chair record in data.
A document for one slug replaces the template for that slug only. It still receives the live record from load.
/products/_template has no published page. Opening the template in the editor previews it with sample().
A record with neither its own document nor a template is a 404.
Blocks
On a page model, every block receives the record as data, next to the fields the editor filled in. A block cannot declare its own field named data.
import { defineBlock, field, type InferBlockProps } from "@backmatter/next";
import type { Product } from "../lib/catalog";
const fields = {
badge: field.text(),
} as const;
export function ProductOverview(props: InferBlockProps<typeof fields, Product>) {
return (
<section>
{props.badge ? <p>{props.badge}</p> : null}
<h1>{props.data.title}</h1>
<p>{props.data.price}</p>
</section>
);
}
export default defineBlock({
name: "ProductOverview",
fields,
component: ProductOverview,
});
InferBlockProps<typeof fields, Product> adds data: Product. Omit the second type argument on blocks that are not used with a source.
A layout for the type receives the same record as its third argument. Existing layouts that take (doc, body) keep working.
import type { ReactNode } from "react";
import type { BackmatterDoc } from "@backmatter/next";
import type { Product } from "../lib/catalog";
export default function ProductLayout(doc: BackmatterDoc, body: ReactNode, data?: Product) {
return (
<article>
{data ? <p>{data.title}</p> : null}
{body}
</article>
);
}
An MDX model can be external too. The template fallback works the same way, and the layout receives data. Tags in the MDX body do not.
A block on an ordinary page, such as a home-page grid that fetches a category itself, does not use a source. It reads its own fields and calls the catalog from the component.
The catch-all
The generated route already passes sources. A route you wrote yourself does the same:
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;
generateStaticParams unions documents of this type (minus _template) with list(). generateMetadata uses the document title, then metadata(), then the slug.
Related
- Fetching & routes is the catch-all this plugs into.
- Fields covers
field.blocks()andformat: "page". - Folder layout lists
sources/next tolayouts/.