BackmatterPricingDocs
Contents

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

FileRole
models/product.tsexternal: true, a route with {slug}, and a blocks field.
sources/product.tsdefineSource. load returns the catalog record for a slug.
content/product/_template.jsonThe page every product renders, until one product has its own file.
blocks/ProductOverview.tsxA 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:

  1. load({ slug }) fetches the catalog record. null is a 404.
  2. The CMS document for oak-chair is used when that file exists.
  3. Otherwise the _template document is used.
  4. The page renders that document with the catalog record.

A type without external: true skips this. Its blocks receive no record.

models/product.ts
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.

sources/product.ts
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 }),
});
MethodWhen it runs
loadEvery real slug. Return null when the catalog has no such record. Required.
listBuild time. Slugs here are prerendered, including products with no CMS file. Omit it to prerender only slugs that have a document.
samplePreview of the template document, so the blocks are not empty. Omit it and that preview gets data: null.
metadataThe 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.

blocks/ProductOverview.tsx
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.

layouts/product.tsx
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:

app/[[...backmatter]]/page.tsx
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.