Configuration
cms-lab is deliberately config-first. Route mappings live in your repo so the scanner follows the same assumptions as your Next.js app.
Example
import { defineConfig } from "@cms-lab/core";
export default defineConfig({
site: { url: "http://localhost:3000" },
framework: { type: "next", router: "app" },
cms: {
provider: "prismic",
repositoryName: "my-repo",
accessToken: process.env.PRISMIC_ACCESS_TOKEN,
},
routes: [
{ type: "page", pattern: "/:uid", getPath: (doc) => "/" + doc.uid },
{
type: "article",
pattern: "/articles/:uid",
getPath: (doc) => "/articles/" + doc.uid,
},
],
checks: {
fields: {
required: [
{ type: "page", path: "title" },
{ type: "article", path: "title", severity: "warning" },
],
},
relationships: [
{
from: "article",
to: "author",
where: { fromField: "author.id", toField: "id" },
min: 1,
severity: "warning",
},
],
},
});Keys
| Key | Required | Meaning |
|---|---|---|
site.url | yes | The local, preview, or staging URL that route probes hit. |
site.healthPath | no | Same-origin path used only by doctor and the initial scan health probe. Use it for localized apps where / redirects or returns a non-OK response but /en is healthy. |
site.healthUrl | no | Absolute URL for a dedicated health check endpoint. Route probes still use site.url. |
framework | yes | Use { type: "next", router: "app" } or { type: "next", router: "pages" }. |
cms | yes | Prismic, Strapi, Directus, WordPress, Contentful, and Sanity provider settings. |
routes | yes | Content type to path mapping. Use getPath when a route is not a simple UID pattern. |
checks | no | Enable, disable, or configure check groups. |
Adapter examples
Adapter configs use the same route and check model. Strapi declares collections and single types; Directus declares collections; WordPress, Contentful, and Sanity declare content or document types. The adapters preserve native CMS fields in document.data, so route mappings and required-field checks can use provider-specific values when your app needs them.
For provider-specific setup notes and caveats, open the provider docs.
cms: {
provider: "strapi",
url: "http://localhost:1337",
token: process.env.STRAPI_TOKEN,
locale: "en",
collections: [
{
type: "page",
endpoint: "pages",
uidField: "routing.slug",
},
],
singleTypes: [
{
type: "navbar",
endpoint: "navbar",
},
],
}
cms: {
provider: "directus",
url: "http://localhost:8055",
token: process.env.DIRECTUS_TOKEN,
collections: [
{
type: "page",
collection: "pages",
uidField: "routing.slug",
},
{
type: "pricing",
collection: "item_branch_pricing",
uidField: "id",
routable: false,
},
],
}
cms: {
provider: "wordpress",
url: "http://localhost:8080",
contentTypes: [
{
type: "post",
endpoint: "posts",
uidField: "acf.handle",
},
],
}
cms: {
provider: "contentful",
spaceId: "my-space",
accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN,
contentTypes: [
{
type: "page",
contentType: "page",
uidField: "routing.slug",
},
],
}
cms: {
provider: "sanity",
projectId: "my-project",
dataset: "production",
contentTypes: [
{
type: "page",
documentType: "page",
uidField: "slug.current",
},
],
}Strapi with Next.js Pages Router
Pages Router projects usually have explicit dynamic routes, so keep the Strapi mapping equally explicit. Single types are scanned for fields, SEO, and images, but they are not treated as missing page routes by default.
import { defineConfig, strapiRelationSlug } from "@cms-lab/core";
export default defineConfig({
site: {
url: "http://localhost:3000",
healthPath: "/en",
},
framework: { type: "next", router: "pages" },
cms: {
provider: "strapi",
url: "http://localhost:1337",
token: process.env.STRAPI_TOKEN,
locale: "en",
collections: [
{ type: "page", endpoint: "pages", uidField: "slug" },
{ type: "article", endpoint: "articles", uidField: "slug" },
],
singleTypes: [
{ type: "navbar", endpoint: "navbar" },
{ type: "footer", endpoint: "footer" },
],
},
routes: [
{ type: "page", pattern: "/:slug", getPath: (doc) => "/" + doc.uid },
{
type: "article",
pattern: "/blog/:topic/:slug",
getPath: (doc) => {
const topic = strapiRelationSlug(doc.data, "topic") ?? "uncategorized";
return "/blog/" + topic + "/" + doc.uid;
},
},
],
});Route fields
Strapi, Directus, WordPress, Contentful, and Sanity entries can define uidField and urlField on each configured collection or content type. Use them when the route value lives in a project-specific field instead of a plain uid or slug.
cms: {
provider: "sanity",
projectId: "my-project",
dataset: "production",
contentTypes: [
{
type: "article",
documentType: "post",
uidField: "slug.current",
urlField: "seo.canonical",
},
],
}document.data. Use dotted paths for nested objects, for example routing.slug or seo.canonical.Required fields
Field checks are project-specific. Use them for CMS values that your app assumes exist at render time.
checks: {
seo: {
metaTitle: true,
metaDescription: true,
// Open Graph / X (Twitter) cards are opt-in. "true" checks og:image.
og: true,
// Or enable more: { image: true, title: true, description: true, twitter: true }
},
a11y: {
imgAlt: true,
},
// Opt into intrinsic-dimension (CLS) checks on CMS images.
images: { dimensions: true },
fields: {
required: [
{ type: "page", path: "title" },
{ type: "article", path: "author.name", severity: "warning" },
],
},
}document.data. Use dotted paths for nested objects, for example author.name.checks.seo.og validates Open Graph and X (Twitter) card fields at the CMS level. It is off by default because many Next.js apps generate social cards at runtime with generateMetadata or next/og rather than storing them in the CMS. Enable it only when editors author those fields.checks: { routes: { canonical: true } } reads each 2xx route body and checks its <link rel="canonical">: a missing canonical is a warning, a canonical on a different origin (a leftover staging hostname) is an error, and a canonical whose path disagrees with the route is a warning. It is off by default because it requires reading response bodies.checks: { routes: { structuredData: true } }parses each 2xx route's application/ld+json blocks: a malformed block is a warning (SEO-JSONLD-INVALID) and a route with no structured data is info (SEO-JSONLD-MISSING). Off by default because it reads response bodies.Relationships
Relationship checks compare one field on a source document with one field on related documents. Use them for simple junction-table rules, such as a catalog item needing at least one pricing row.
checks: {
relationships: [
{
from: "menu_item",
to: "pricing",
where: { fromField: "id", toField: "menu_item_id" },
min: 1,
severity: "warning",
},
],
}CMS-RELATIONSHIP-UNPUBLISHED, since the live page links to nothing live at runtime.Localization
Localization checks flag locale-parity gaps: a content group that is published in some locales but missing in others. Documents are grouped by groupField (defaulting to the document UID, common when locales share a slug), and only published documents count, so a translation that is still a draft is reported as missing.
checks: {
localization: {
locales: ["en", "fr", "de"],
// localeField defaults to "locale"; groupField defaults to the document UID.
localeField: "locale",
groupField: "translationKey",
types: ["page", "article"],
severity: "warning",
},
}checks.localization is set, and emits CMS-LOCALE-MISSING for each group missing a locale. Set types to limit it to localized content types.seo.metaTitle, Directus seo.title, WordPress yoast_head_json, Strapi alternativeText, Directus image descriptions, WordPress alt_text, Contentful asset descriptions, and Sanity image alt fields.Custom rules
Custom rules cover project-specific invariants that the built-in route, field, SEO, and image checks do not. They come in two forms: declarative rules for the common case and functional rules for the rest. Both produce normal diagnostics, so they show up in the terminal, JSON, Markdown, JUnit, Slack, and HTML reports, and they respect --only custom and --skip custom.
A declarative rule applies to one content type, reads the value at path from document.data, and emits a diagnostic when assert fails. An optional filter narrows the rule to documents whose fields match.
checks: {
custom: [
// active menu items must have a price above zero
{
code: "MENU-PRICE",
type: "menu_item",
path: "price",
assert: { gt: 0 },
severity: "error",
message: "Menu item price must be greater than 0",
},
// legal pages must have been reviewed in the last 12 months
{
type: "page",
filter: { template: "legal" },
path: "last_reviewed_at",
assert: { newerThan: "12months" },
severity: "warning",
code: "LEGAL-REVIEW-OVERDUE",
},
// event dates must be in the future
{ type: "event", path: "eventDate", assert: "futureDate" },
// image descriptions must not be placeholder text
{
type: "menu_item",
path: "image.description",
assert: { notMatches: "^(image|photo|picture)$" },
code: "IMG-DESC-PLACEHOLDER",
},
],
}Supported assertions: present, futureDate, pastDate (string shorthands), and the object form with gt, gte, lt, lte, oneOf, matches, notMatches, minLength, maxLength, newerThan, and olderThan. Every constraint in an object assertion must hold for the rule to pass. Durations accept values such as 30d, 2 weeks, 12months, or 1y.
A functional rule is a function called once per document. It receives the document and a context with readPath plus error, warning, and info helpers. Use it for cross-document or multi-field checks the declarative form cannot express.
checks: {
custom: [
(doc, ctx) => {
if (doc.type !== "branch") return;
const items = ctx.readPath("available_items");
if (!Array.isArray(items) || items.length === 0) {
ctx.error(
"BRANCH-NO-ITEMS",
"Branch " + doc.id + " has no available items",
{ path: "data.available_items" },
);
}
},
],
}CUSTOM-RULE code; set code to give a rule its own. Diagnostics whose code starts with CUSTOM are grouped under a custom group in the HTML report and JUnit output; functional rules can use any code you like.