@bison-lab/payload-core
v3.21.0
Published
Site-agnostic Payload CMS configuration for Bison Lab sites: the SEO tab, the Theme global, the Roles Global, the brand-assets cupboard, and the metadata and theme readers for the pages they describe
Readme
@bison-lab/payload-core
Site-agnostic Payload CMS configuration for Bison Lab sites: the SEO tab on a collection, the Theme Global, the Roles Global, and the readers a page route and a root layout use. Collection factories join it here as they are extracted.
Full guide: https://payload.bisonlab.ai/site/seo/
pnpm add @bison-lab/payload-core @payloadcms/plugin-seo @bison-lab/tokens @bison-lab/fontsPeers: payload and @payloadcms/plugin-seo are required. @bison-lab/tokens
and @bison-lab/fonts are needed to adopt the Theme Global; @payloadcms/ui
and react are needed for its admin fields; @bison-lab/ui and
@payloadcms/live-preview-react are needed for the live theme preview.
Pin the plugin to the same version as payload; Payload releases them in
lockstep.
Six entry points, and why
| Import | Contents | Runs where |
| ---------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| @bison-lab/payload-core | seoPlugin, createTheme, createBrandAssets, createRoles, createFeatures, createPages, createUsers, createMedia, createAdminNav, adminNav, createNavigation, adminOnlyApiTab, documentTitleActions, seedTheme, seedRoles, seedFeatures, predicates, lookField, colorTokenField, import-map strings, title and text helpers, types | Node. What payload.config.ts imports; it loads the plugin. |
| @bison-lab/payload-core/metadata | pageMetadata, the title helpers, the same types | Server. What a page route imports; it does not load the plugin. |
| @bison-lab/payload-core/theme | getPublishedTheme, getPublishedIdentity, themeConfigFromDoc, themeHead, themeHeadFromDoc | Server. What a root layout imports; it does not load the plugin or React. |
| @bison-lab/payload-core/navigation | getNavigation, the Header and Footer slugs | Server. What a site header imports; it does not load the plugin or React. |
| @bison-lab/payload-core/admin | Theme fields, the Roles and Features matrices, Admin nav, and the Users Roles checklist | Admin. Referenced by import-map string; generate:importmap writes it. |
| @bison-lab/payload-core/react | ThemePreview (legacy; Theme has no preview pane) | Client. Kept so an older site import does not break. |
What editors get
An SEO tab beside the page's Content tab, never below the block editor: an overview with character counts, meta title and description with Generate buttons, a share image from the media collection with its own Generate button when the site can name an image already on the page, a search-result preview, and a Hide this page from search engines switch, off by default.
Wiring a site
1. Configure the plugin. Every string it generates comes from here, so the site spells its name and its URL scheme once.
// payload.config.ts
import { firstImageIn, seoPlugin } from "@bison-lab/payload-core";
import type { Page } from "@/payload-types";
export default buildConfig({
plugins: [
seoPlugin<Page>({
siteName: "Acme Clinics",
urlFor: (doc) => (doc.slug ? absoluteUrl(pagePath(doc.slug)) : undefined),
describeFrom: (doc) => doc.hero?.[0]?.body,
imageFor: (doc) =>
firstImageIn([...(doc.hero ?? []), ...(doc.layout ?? [])]),
}),
],
});collections defaults to ['pages'] and uploadsCollection to 'media'.
urlFor answering undefined leaves the preview empty rather than pointing
it at the home page while the slug is still blank. describeFrom and
imageFor are optional; without them those Generate buttons fill in nothing
(the image button does not appear at all).
2. Cut the share rendition. Add SHARE_IMAGE_SIZE to the upload
collection so every image gets a 1200x630 card:
import { SHARE_IMAGE_SIZE } from "@bison-lab/payload-core";
export const Media: CollectionConfig = {
slug: "media",
upload: { imageSizes: [SHARE_IMAGE_SIZE] },
fields: [{ name: "alt", type: "text", required: true }],
};Then payload generate:types, payload generate:importmap (the plugin's
admin components resolve by string key from the site's import map) and
payload migrate:create.
3. Build the site's title template from the same helper. Next applies
title.template to <title> alone, and pageMetadata needs the completed
title for Open Graph, so both read one spelling:
// app/layout.tsx
import { titleTemplate } from "@bison-lab/payload-core/metadata";
export const metadata: Metadata = {
title: { default: SITE_NAME, template: titleTemplate(SITE_NAME) },
};4. Read the tab in the page route.
// app/[...slug]/page.tsx
import { pageMetadata } from "@bison-lab/payload-core/metadata";
export async function generateMetadata({ params }): Promise<Metadata> {
const page = await getPage(params);
if (!page) return {};
return pageMetadata(page, {
siteName: SITE_NAME,
canonical: absoluteUrl(pagePath(page.slug)),
absoluteUrl,
});
}pageMetadata returns <title>, description, canonical, Open Graph and
Twitter with the share image (falling back to the original file when the
rendition is missing), and noindex, nofollow when the page is hidden. A
written meta title is absolute, so the layout's template does not append
the site name twice. Fields the editor left empty are omitted rather than
set to undefined, so the layout's own description and default share image
carry through Next's metadata merge.
The sitemap is the site's: filter meta.noIndex out of it.
Adopting the Theme global
Theme in the admin nav is one Global (theme) in admin.group: "Theme".
Colors, Typography, Appearance, and Identity are field tabs on that
document, not custom views and not extra Globals. Save is Payload's Save
on the whole form — every tab is hydrated from the live row, so an
untouched tab does not revert. getPublishedTheme reads that same row.
Document locking is off. Theme has no draft mode and no preview pane.
Contrast is the automatic label on editable fills at 7:1; it
is never enforced and lives in the Needs-attention modal, not on the page.
bison.config.json is the seed a site starts from, not the source of truth.
Pass destructive (or seed.brandDestructive); otherwise Theme uses the
library’s DESTRUCTIVE_SCALE_HEX (#ef4444). Success falls back to #22c55e.
1. Register the Global. Access is required — Theme does not read the
Roles or Features Global. Pass canUseFeature("theme") as update
(sites can keep canManageBrand until they adopt the switchboard).
// payload.config.ts
import {
BRAND_ASSETS_SLUG,
createBrandAssets,
canUseFeature,
createFeatures,
createRoles,
createTheme,
isAuthenticated,
} from "@bison-lab/payload-core";
import bisonConfig from "../bison.config.json";
export default buildConfig({
collections: [
createBrandAssets({
access: { read: () => true, update: canUseFeature("brand-assets") },
}),
],
globals: [
createRoles(),
createFeatures(),
...createTheme({
access: { read: isAuthenticated, update: canUseFeature("theme") },
seed: bisonConfig,
logo: { collection: BRAND_ASSETS_SLUG },
identity: {
fallback: {
lockup: { url: "/logo-lockup.svg", alt: "Acme" },
mark: { url: "/logo-mark.svg", alt: "Acme mark" },
},
},
colorUsages: true,
onPublish: async () => {
revalidateTag("theme");
},
}),
],
});Color usages. createTheme({ colorUsages }) is the only site wiring for
"is this custom color assigned?" Pass true to scan every collection and
global Payload has registered when Colors Delete runs — drafts included,
blocks included. Payload internals (payload-*) and Theme itself are
skipped, so the library row being deleted is not treated as a use, and a
later Navigation or Territories global joins without a site change. A
{ collections, globals } list still names slugs when a site wants a
narrower walk. Omit the option, or pass empty arrays, and every
additional color is unused and deletes immediately. The site does not
copy findColorTokens.
Then payload generate:importmap (the Theme fields resolve from
@bison-lab/payload-core/admin), payload generate:types, and
payload migrate:create.
2. Seed the row on deploy. A migration up() writes a published version
so the site renders what bison-theme.css rendered before anyone opens the
admin:
import { seedFeatures, seedRoles, seedTheme } from "@bison-lab/payload-core";
import bisonConfig from "../bison.config.json";
export async function up({ payload }) {
await seedTheme(payload, bisonConfig);
await seedRoles(payload); // pass the same extras as createRoles, if any
await seedFeatures(payload); // pass the same extras as createFeatures, if any
}3. Serve the catalogue. A Next route around serveFont from
@bison-lab/fonts/server, listed in serverExternalPackages, at the path
you pass as fontsBaseUrl (default /fonts).
4. Render the published theme in the root layout.
// app/layout.tsx
import { getPublishedIdentity, getPublishedTheme, themeHead } from "@bison-lab/payload-core/theme";
import bisonConfig from "../bison.config.json";
const theme = await getPublishedTheme(payload, bisonConfig);
const identity = await getPublishedIdentity(payload, {
lockup: { url: "/logo-lockup.svg", alt: "Acme" },
mark: { url: "/logo-mark.svg", alt: "Acme mark" },
});
const { css, preloads } = themeHead(theme, { identity });
// Custom Colors need the Theme document: themeHeadFromDoc(doc, bisonConfig, { identity })
// emits --coral-* and [data-look] so lookField keys paint.
// <link rel="preload" as="font" type="font/woff2" crossOrigin="" href={href} />
// <style dangerouslySetInnerHTML={{ __html: css }} />
// identity.lockup / identity.mark / identity.favicon — uploaded file or the fallbackIf the admin layout renders the same head, the admin panel restyles too.
getPublishedTheme reads the live row and falls back to the seed when
the Global is empty. Empty logo, favicon, and mobile-menu mark use the
fallback the site passed into createTheme. There is no Theme preview pane.
Brand assets
Logo, favicon, and mobile-menu mark live in a locked cupboard, not in
ordinary Media. Call createBrandAssets and point Theme at
BRAND_ASSETS_SLUG. Access is the same shape as createTheme: read is
typically public so the live site can load the file; update is
canUseFeature("brand-assets") (or canManageBrand until the site
adopts the switchboard) and covers create, update, delete, and version history.
SVG preferred, PNG and ICO allowed; an SVG is sanitized on upload.
Versions stay on so a replaced file can be rolled back.
Each row carries a label, usage notes, and required alt text so a logo
has its accessible name. Then payload migrate:create.
Roles
Settings → Roles is an Admin-editable array. Rank is drag-to-reorder.
Ticks are the catalogue rows released on Features. Developer is
implicit — every catalogue feature, including ones not released. That
row is visible only to a Developer, with every tick on and locked.
Defaults ship in the package (Theme
screens on Designer; Designer also has Users and Roles; Admin + Designer
both store). An Admin may remove Admin, Designer, or Author after first
run; only Developer stays. A site may pass extras into createRoles —
each extra is a slug, a display label, and default grants — and an
Admin can add further custom rows and edit any row’s name. Remaining
seed slugs stay read-only.
canManageBrand and the other predicates read the saved Global and fall
back to that seed (plus extras). The package never writes developer
onto a user row.
createRoles({
extras: [{ role: "editor", label: "Editor", grants: ["content"] }],
});Features
Settings → Features is a Developer-only release valve: one switch per
catalogue row. Off hides the tick on Roles; Developer still has the
feature. The catalogue is code, grouped for the UI: Pages (Content,
Publish), Media, Theme (Colors, Typography, Appearance, Identity, Brand
assets), and Users (Users, Roles). Features itself is not a row. The
document API tab and Better Editor overlay settings are not rows either
— they stay code-locked to Developer (adminOnlyApiTab,
developerOnlyAccess / hideUnlessDeveloper). A site may pass extras
into createFeatures — those rows appear only there, including the
Navigation tick createNavigation reads:
createFeatures({
extras: [
{ slug: "doctors", label: "Doctor search", group: "users" },
{ slug: "navigation", label: "Navigation" },
],
});canUseFeature(slug) is true when the row is released and a held role
is granted it, or when the login is Developer. An empty Global falls
back to the catalogue defaults. Group slugs pages and theme are
true when any child is. createPages / createMedia use
canUseFeature for create and update (and admin.hidden). Theme and
brand-assets still take access arguments — pass
canUseFeature("theme") / canUseFeature("brand-assets") as update.
admin.hidden is presentation; Access Control is enforcement. Nav
hiding follows user.allowedFeatures on the JWT (computed at user
read from the live Features and Roles Globals) so Payload's
admin.hidden({ user }) matches the API.
Pages, users, and media
createPages, createUsers, createMedia, slugField,
adminOnlyApiTab, and documentTitleActions ship from the same
React-free entry. Pages take the
site's hero and layout blocks, reserved-slug predicate, and preview
path. Create follows the Content tick. Update without Publish is
constrained to _status: draft — an Author can save a draft and cannot
publish or edit a live page. Users sit under Settings next to Roles,
take secureCookies, and offer whatever rows the Global (or the seed
plus the same extras) has. users.roles and users.allowedFeatures
set saveToJWT: true. The checklist labels are the editable names.
adminOnlyApiTab shows the document API tab only to Developer.
documentTitleActions is one admin provider that puts every document's
primary actions on the title row — the same slot collection lists use
for Create New — and hides the dead Edit tab when it has no sibling.
One-list Globals pass documentCreateNew("roles") (or a site field
path) so Create New uses those same words, with no icon. Overlay
settings (Better Editor) take developerOnlyAccess and
hideUnlessDeveloper on the site — the package does not add that
plugin as a peer.
Admin nav
Settings → Admin nav is Developer chrome, not a Features row. A Developer adds, renames, deletes, and drags group headers and the collections or globals under each. An Admin never opens the editor. Every login can read the document so the sidebar can paint. Save writes immediately.
adminNav() sets admin.components.Nav to the package Nav. A consuming
site must not write a Nav component. An empty document falls back to
Payload's first-seen walk (collections, then globals, bucketed by
admin.group). Once the document has groups, that list is the sidebar —
omitted collections and globals stay off it. group: false stays off.
resolveAdminNav is the layout source so a later skin (SPI-10) does not
invent a second one.
import { adminNav, createAdminNav } from "@bison-lab/payload-core";
export default buildConfig({
plugins: [adminNav()],
globals: [createAdminNav(), createRoles(), createFeatures()],
});Then payload generate:importmap and payload migrate:create.
Header and Footer
createNavigation({ blocks }) returns two Globals so each has its own
Save / Publish / drafts. Header keeps slug navigation and table nav
so an existing one-document row does not move. Footer is
navigation-footer / navf — short because nested footer arrays hit
Postgres's 63-character identifier cap the same way nav did. Both sit
in admin.group: "Navigation" until a Developer moves them from Admin
nav. One Features tick (navigation, passed as a createFeatures
extra). Publish still needs the Publish tick. getNavigation({ payload,
draft }) finds both and returns { header, footer, bar }.
import { createNavigation } from "@bison-lab/payload-core";
import { getNavigation } from "@bison-lab/payload-core/navigation";
globals: [
...createNavigation({
blocks: [megaMenuBlock({ variants }), LinkBlock],
preview: () => "/preview",
}),
];
// In a site header — the /navigation entry keeps the SEO plugin off the layout.
const { header, footer, bar } = await getNavigation({ payload, draft });header.items uses @bison-lab/payload-blocks/admin#NavItemsField in
place of the stock blocks UI. A site that already generates the blocks
admin map picks it up with payload generate:importmap; until it does,
the field renders as nothing. Header Settings (bar) stores hide on
scroll, shared viewport, and the default panel width. Featured-link
looks stay on chrome roles, not a variants array on this document. A
site without the consume bump (SPI-99) never sees the Settings tab —
run payload generate:types and payload migrate:create after this
schema change. getNavigation returns { header, footer, bar }.
A site that already stored footer columns on the nav document copies
footer.columns onto the new global, then drops the footer fields from
nav.
No generated types
The package cannot import a site's payload-types, so the shapes it reads
(SeoMeta, SeoImageDoc, SeoPage, ThemeDoc, RolesDoc, AdminNavDoc, NavigationDoc, NavigationBar) are hand-written and
structural. A generated Page, Media or Theme Global is assignable to
them; nothing carries an index signature, since an interface will not assign
to a type that has one.
Changing a field
noIndexField, the plugin's own fields, the Theme Global fields, the
Roles Global fields, the Admin nav fields, the Header and Footer fields,
and the brand-assets collection fields are columns
in every consuming site. A change to them is a schema change: say "run
payload migrate:create" in the changeset.
