npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bison-lab/payload-core

v4.28.2

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/

pnpm add @bison-lab/payload-core @payloadcms/plugin-seo @payloadcms/plugin-redirects @bison-lab/tokens @bison-lab/fonts

Peers: payload, @payloadcms/plugin-seo, and @payloadcms/plugin-redirects are required. @bison-lab/tokens and @bison-lab/fonts are required because Theme is required. @payloadcms/ui, next and react are needed for admin fields (./admin refreshes the Media list with next/navigation, which Payload's admin already loads); @bison-lab/ui and @payloadcms/live-preview-react are peers of the ./react entry. Pin the plugin to the same version as payload; Payload releases them in lockstep.

Ten entry points, and why

| Import | Contents | Runs where | | ---------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | | @bison-lab/payload-core | seoPlugin, createTheme, createBrandAssets, createRoles, createFeatures, createBlockLibrary, createPages, createUsers, createMedia, createRedirects, createQuestions, createTaxonomy, facetField, createAdminNav, adminNav, createNavigation, adminOnlyApiTab, documentTitleActions, listAlign, blockDrawer, richTextEditorFonts, 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/block-library | getSectionControls, sectionControlsFromLibrary, SectionControls, the catalogue readers, the island height message | Server. What a page route and the Block Settings fold import; it does not load the plugin or React. | | @bison-lab/payload-core/taxonomy | facetField, FacetEntry | Anywhere. What a block config imports to tag content from a taxonomy; no plugin, no React, so a browser bundle can take it (@bison-lab/payload-blocks main entry reaches the docs island and Storybook). | | @bison-lab/payload-core/block-thumbnails | captureThumbnails, thumbnailCss, NEUTRAL_THEME, thumbnailVersion; the bison-block-thumbnails bin | Node, beside Playwright. What a site's thumbnail script imports; no Payload, no React. | | @bison-lab/payload-core/wireframe | Deprecated: the 4.15 wireframe generator and the bison-wireframes bin | Node. Kept for one release so a site's script keeps working; use ./block-thumbnails. Goes in the next major. | | @bison-lab/payload-core/admin | Theme fields, the Roles and Features matrices, the Block library field, Admin nav, the Users Roles checklist, and the FAQ Library list | Admin. Referenced by import-map string; generate:importmap writes it. | | @bison-lab/payload-core/react | BlockLibraryIsland; ThemePreview (legacy; Theme has no preview pane) | Client. What a Block library preview route wraps its sample in. |

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 { createRedirects, firstImageIn, seoPlugin } from "@bison-lab/payload-core";
import type { Page } from "@/payload-types";

export default buildConfig({
  plugins: [
    createRedirects(),
    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.

Theme

Theme is required. The admin nav shows 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 (#FF7C6B). Success falls back to #83CEA2. Changing a brand hex or This color is leaves the chips as they were; Regenerate scale rewrites the ramp with the hex pinned at that step. Success and Destructive family banners use the same scale steps as the visitor Alert.

1. Register the Global. update defaults to canUseFeature("theme") (Designer in the seed). Pass access.read to tighten who can read the document. Omitting access still registers the Global. Features does not install or remove it.

// payload.config.ts
import {
  BRAND_ASSETS_SLUG,
  createBrandAssets,
  createFeatures,
  createRoles,
  createTheme,
} from "@bison-lab/payload-core";
import bisonConfig from "../bison.config.json";

export default buildConfig({
  collections: [createBrandAssets()],
  globals: [
    createRoles(),
    createFeatures(),
    ...createTheme({
      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 fallback

If 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.

richTextEditorFonts() sets the admin's Rich Text editor (and its version diff) in var(--font-body) with normal tracking. Payload writes that prose in var(--font-serif), so without it what an editor types reads in a different face from the page and the live preview (BIS-168). It is one admin provider (RICH_TEXT_EDITOR_FONTS) and needs the admin layout to render the Theme head, which is what defines --font-body there. Headings inside follow whatever the admin sets on h1–h6. Register it in plugins, then payload generate:importmap.

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 matches createTheme: read defaults to public so the live site can load the file; update defaults to canUseFeature("theme") 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 (the Theme grant 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.

Removing a row removes that slug from every user in the same save, so a later row saved under the same slug cannot hand it back. A user whose only role it was is left with none. A save that would leave no user holding Developer or a role with the Users tick is refused (ROLES_LOCKOUT_MESSAGE), whether by deleting the row or unticking Users. pruneUserRoles(req) strips every slug not on the saved Global; call it once from a migration to clean users saved before this rule.

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, and Users (Users, Roles). Theme is not a row. Features does not install or remove the Theme Global. 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. canUseFeature("theme") is the Roles grant only. An empty Global falls back to the catalogue defaults. The group slug pages is true when any child is. createPages / createMedia use canUseFeature for create and update (and admin.hidden). createTheme and createBrandAssets default update to canUseFeature("theme"). That grant opens the Theme document and brand-assets. A site may pass access.read to tighten read. admin.hidden is presentation; Access Control is enforcement. hideUnlessFeature("theme") hides Theme in the nav for a role with no grant. getPublishedTheme and themeHead still return the saved document. 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.

Block library

Settings → Block library is one card per installed option. Editors browse package categories (Hero, Feature, Process, FAQ, Testimonials, Stats, Content, Contact, CTA, Navigation) and switch a card on or off. Off is editorial availability. Save is Payload's document chrome. The field is BLOCK_LIBRARY_FIELD (@bison-lab/payload-core/admin#BlockLibraryField): a category rail and a gallery of islands. Each island is an iframe of previewPath(slug).

The rail (BIS-143) is admin-ui's CategoryRail: a search box over an accordion. Each header reads Category (n), n counting every block in it, switched-off ones included; an empty category has no header. One category is open at a time and the gallery shows all of its cards in catalogue order; closing the open header keeps those cards on screen. Under the open header each block is a #<slug> link to its card (the card's id), and the link for the card in view is marked as you scroll. A URL with #<slug> opens that block's category and scrolls to its card, on load and on every hash change. A query matches a block's label or slug, or a category's name: every matching category opens, counts become matches, the gallery shows the matching cards under their category headings, and an empty result says so with a Clear search button. Clearing returns to the category that was open; clicking a header mid-search opens that whole category. At 768px and below the rail stacks above the gallery as the same accordion. Islands load lazily. That route, and filterOptions that hide an off slug, are the consuming site (SPI-53). Islands are meant to render the sample under themeHead.

Wrap the sample in BlockLibraryIsland (@bison-lab/payload-core/react) and the card is as tall as the block (BIS-147). The island pads the sample evenly and posts its height to the admin window; the card takes it from its own frame on its own origin only, between a floor (an empty or position: fixed sample stays a visible card) and a ceiling (a vh-sized sample cannot grow the card forever). An island that never reports keeps the fixed 720px card.

megaMenu and navLink are header item blocks, not cards. Navigation's card is floatingNav. While it is the only navbar, that card has no switch: there is nothing else to turn on. A second header brings the switches back, so an editor picks which one the site uses. A site extra is appended with blocks and appears only in the category it names. A site may not add megaMenu or navLink as cards.

Turning a card off distinguishes published from draft:

  • The last hero or the last navbar is refused.
  • Published — a live page (or any live document) holds the slug, or it is the published Header's navbar: refused. The message names the published documents and mentions any drafts; nothing is deleted.
  • Drafts only — the switch opens a ConfirmDialog that names the drafts. Confirm turns the switch off; the Save that turns the slug off deletes its rows from those drafts, each written as a new draft version (payload.update({ draft: true })) inside the Save's transaction. Published versions are never written. Cancel leaves it on.
  • Unused — off, no dialog.

Save leaves the form clean: the field reads each row from Payload's own row fields and writes one column at a time, never the array path and never on load, so after a Save the button disables again, leaving the page asks nothing, and a switch flipped straight after a Save holds (BIS-135).

The server is the authority: the Save hook re-reads usage, refuses while anything published holds the slug, and strips drafts otherwise, whether the Save came from the admin or the API. The admin asks GET /api/globals/block-library/usage?slug=… (same access as update) before it turns a switch off. blockUsage and stripDrafts are exported. A page whose only section was stripped stays a draft until a section is added: publish still hits LAYOUT_REQUIRED_MESSAGE. A block embedded in rich text (a Lexical block node) is stripped too. A refusal is a public 400, so the admin shows its message. A read that fails while checking usage fails the check (the switch stays on) rather than answering from part of the site. An editor who has a stripped draft open still holds the row in their form; their next autosave writes it back, so turn a block off when nobody is editing the drafts it names.

The Header's navbar is header.navbar when the Header document carries one, otherwise the catalogue's first navbar (floatingNav). The package Header has no such field yet; the picker arrives with a second navbar package (BIS-132), so until then the published Header always holds floatingNav.

import { createBlockLibrary } from "@bison-lab/payload-core";

createBlockLibrary({
  previewPath: (slug) => `/block-library/${slug}`,
  blocks: [{ slug: "ctaBand", label: "Doctor finder", category: "cta", kind: "section" }],
});

access.update defaults to isAdmin. Pass access to change who may edit availability. Then payload generate:importmap and payload migrate:create.

The Add Block drawer

Payload has no slot to replace its Add Block drawer, and three places open it: the blocks field's Add button, Add below in a row's ⋯ menu, and the Preview overlay. blockDrawer() (BIS-148) sets what that drawer reads on every catalogue block, in every collection and global, so all three agree:

  • admin.group: the block's Block library category label, with the list ordered by category, then catalogue order. The drawer reads Hero, Feature, Process, … like the Block library. Blocks outside the catalogue (a Header's megaMenu and navLink) are left alone and go last, which is also where Payload draws ungrouped blocks.
  • admin.images.thumbnail: a screenshot of the block's sample (generic copy and photos from @bison-lab/payload-blocks) under a neutral slate theme (BIS-151). The package ships one for each of its page blocks (block-library/thumbnails.generated.ts, resources included) and serves it from GET /api/block-library/thumbnail/:slug, a root endpoint the plugin registers: signed-in users only, image/jpeg, cached for a year behind a ?v= that changes with the picture. The admin's client config carries only that URL. A site extra passes its own thumbnail URL on its createBlockLibrary entry (a file it serves, such as /block-thumbnails/ctaBand.jpg from public/); it is kept in the Global's server-only custom. A block config that already sets a thumbnail or imageURL keeps it.
  • Larger cards: one admin stylesheet (BLOCK_DRAWER_CSS, mounted as the BLOCK_DRAWER_STYLES provider) lays the drawer out three across, two at Payload's 1024px break, one at 768px, instead of Payload's six.

Which blocks the drawer offers is still the site's filterOptions from the Block library; this plugin changes how they look, not which appear.

import { blockDrawer, createBlockLibrary } from "@bison-lab/payload-core";

export default buildConfig({
  globals: [
    createBlockLibrary({
      previewPath: (slug) => `/block-library/${slug}`,
      blocks: [{ slug: "ctaBand", label: "CTA band", category: "cta", thumbnail: "/block-thumbnails/ctaBand.jpg" }],
    }),
  ],
  plugins: [blockDrawer()],
});

Run payload generate:importmap once for the stylesheet provider.

Screenshots come from bison-block-thumbnails, a bin on this package that opens each block's Block library preview in a headless browser at 1280px, lays the neutral theme over the site's (thumbnailCss: the NEUTRAL_THEME tokens from buildThemeCss, a system font stack, no motion, dev-server chrome hidden), centers the block in a 3:2 box, and saves a 960 × 640 JPEG (captureThumbnails, from the ./block-thumbnails entry). Run it from the site, which supplies Playwright:

THUMBNAIL_EMAIL=… THUMBNAIL_PASSWORD=… pnpm exec bison-block-thumbnails \
  --base http://localhost:3039 --slugs ctaBand,productMap \
  --out-dir public/block-thumbnails

The login (any user the preview route accepts) comes from the environment and is required unless --no-login names a public route. --preview changes the route pattern (/block-library/{slug} by default; a site can add a query, such as ?thumbnail=1, to swap in generic stand-ins for data its sample reads), and --selector the element that holds the sample (BlockLibraryIsland's by default). --out-dir writes <slug>.jpg files; --module writes a TypeScript module of base64 JPEGs (--export names the constant), which is how this package ships its own. A preview that answers an error status, or renders nothing the selector matches, fails the run and writes nothing, so a sign-in wall or a mistyped slug never becomes a block's picture. Rerun it when a block's layout or sample changes. To refresh the package's own, run it with --module src/block-library/thumbnails.generated.ts --export PACKAGE_THUMBNAILS against a site's preview route with this release's payload-blocks installed; the hero alias reuses the sweep hero's picture, since no site installs it.

Section controls

Every page block carries the same four section settings (Background, Spacing, Anchor, Hidden; withSectionSettings in payload-blocks). Which of them a block offers is data, not code: four ticks under Enabled on each card, stored on the row as sectionControls, so turning one control off for one block is a Save, not a release. The words are the fold's; a legend above the cards says what each one does. A tick off hides the control in the editor and the renderer ignores its stored value (payload-blocks, BIS-138). The four columns on every page block stay; no page schema moves when a tick changes.

The row is seeded from the catalogue entry's sectionControls, and a key the entry leaves out follows its kind: a hero offers Anchor only (Background would fight the hero's own tone, Spacing is header clearance, and a hidden hero is a published page with no opening), a section offers all four, a navbar none and draws no ticks. The package blocks are written out that way in the catalogue. A site extra passes its own the same way it passes sample:

createBlockLibrary({
  previewPath: (slug) => `/block-library/${slug}`,
  blocks: [
    { slug: "ctaBand", label: "Doctor finder", category: "cta", kind: "section",
      sectionControls: { spacing: false } },
  ],
});

stampBlockLibrary keeps a stored tick and fills a row that has none from the entry, so a block that arrives in a later release gets its default without a migration beyond the new columns. validateBlockLibrary refuses Hidden on a hero row; the card locks that tick. A disabled block keeps its ticks; they matter again when it is re-enabled. Off is off for Admin; Developer is not special here.

Reading them: sectionControlsFromLibrary(doc, extras?) maps every slug to its controls: the stored ticks when the document holds the row (a site extra's saved row included, without the site passing anything), else the catalogue default; a slug in neither is absent from the map, and a consumer treats that as every control. getSectionControls(payload) reads the Global (access overridden) and maps it, for a page route to hand RenderBlocks as sectionControls; it takes a site extra's catalogue default from the registered Global's config (blockLibraryEntriesFromConfig), so the Block Settings fold and the page agree on every block. Both are on @bison-lab/payload-core/block-library, so a page route never loads the SEO plugin for them. Adding the columns is a schema change: run payload migrate:create.

Pages, users, and media

createPages, createUsers, createMedia, slugField, createRedirects, adminOnlyApiTab, documentTitleActions, and listAlign ship from the same React-free entry. Pages take the site's hero and layout blocks, reserved-slug predicate, preview path, and optional folderRoots (patients, providers, …). Parent on the page editor is where the page lives: site root, those roots, published CMS pages, folders created by earlier saves, or New folder…. The address is that folder plus a title leaf until first publish, then locks. Every site has one required home page. Its pageKey is /, and that key — not the title, and not a slug of home — is how the row is found. The Address field and the slug column show /. The pages list has a Required column with a lock on any row whose pageKey is set. Home cannot be deleted, unpublished, or moved. Call ensureRootPage from onInit and pass the hero and a section; the stored slug is an internal token (ROOT_PAGE_SLUG) so a draft that has no address yet does not collide with it. requiredKeys marks further pages the same way (find-a-doctor); those keys are not paths. Preview of home calls previewPath("/"). An empty string is still a page with no address. Regenerate the import map so the Required cell and the slug cell resolve. Layout carries the same standing copy as the hero — a page cannot be published without a section — and Publish names that fix instead of only Content → Layout. Title-follow does not wipe the folder prefix. A taken address is refused — never rewritten to slug-2. Unlock opens Parent and a "Page name in address" box (with Use title), and a parent or page-name change writes a 308 the same way a typed slug change does (BIS-171). The lock is a beforeValidate hook that keeps the stored address, not field access.update: Payload renders a field with update denied as read-only when the edit view loads, so Unlock could never open it. createRedirects() wraps @payloadcms/plugin-redirects for pages only (permanent 308) and collapses chains when a published slug changes. The plugin stores the rows; the site still has to serve them. Pin the plugin to the same version as payload, then payload generate:importmap, payload generate:types, and payload migrate:create. 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. listAlign() keeps a list column's header and cells together: a field declares admin: { custom: { listAlign: "center" | "end" } } and the plugin writes one stylesheet (listAlignCss) from every collection's fields, mounted as the LIST_ALIGN provider. Payload's header holds a full-width flex .sort-column, so text-align alone never moves it; centered, the label shrinks to its word and the sort arrows hang outside it, and end-aligned, the arrows lead. Never write a per-column selector in a site stylesheet. The kit Table takes the same idea as align={[…]}, one entry per column track. 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. The Header edit view is @bison-lab/payload-blocks/admin#AdminWorkspace. Footer is the same workspace on navigation-footer; footer is a group whose Field is @bison-lab/payload-blocks/admin#FooterColumnsField. Columns hold sections (a required section label plus links). Link-column desktop widths add to 100% of the space beside the logo, not of the full footer. Brand copy and social URLs sit on the Logo column. The copyright line and "Built by Bison Lab." are code-locked. A site that already generates the blocks admin map picks both editors up with payload generate:importmap; until it does, those slots render as nothing. bar stores hide on scroll, shared viewport, and the default panel width, and is hidden in the admin. Featured-link looks stay on chrome roles, not a variants array on this document. A Footer consume runs payload generate:importmap, payload generate:types, and payload migrate:create — existing footer.columns rows are { heading, links } and need a wrap into { desktop, sections }. Link-column widths that used to include the logo in the 100% need a remap (Normalize desktop). 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.

FAQ Library

createQuestions is one document per FAQ (question, richText answer, drafts) with a kit List that replaces Payload's stock collection list. The answer is Payload's default Lexical editor — the same one the pages rich-text block uses; the kit dialog mounts it, not the stock document form. Hidden answerText is the varchar the list searches; syncAnswerText (the collection beforeChange hook, also exported) writes it whenever answer is in the incoming data. A site that replaces hooks.beforeChange must keep that hook or search stops matching answers. Do not contains on answer — Postgres rejects ilike against jsonb. createTaxonomy is a name-only terms collection, hidden from the nav; add and delete for manage: true facets happen on that list. Pass Anatomy-like collections and Topics through organizeBy — the package does not know "anatomy". Feature slug questions. Used-on stays 0 until a later block stores relationships.

createTaxonomy({
  slug: "topics",
  labels: { singular: "Topic", plural: "Topics" },
  access: { read: () => true, update: canUseFeature("questions") },
});

createQuestions({
  organizeBy: [
    {
      name: "anatomy",
      label: "Anatomy",
      collection: "anatomy",
      manage: false,
      look: { global: "anatomy" },
    },
    { name: "topics", label: "Topics", collection: "topics", manage: true },
  ],
});

Each organizeBy entry's field is facetField(entry): a stock relationship, hasMany, onto collection, with Payload's default allowCreate, so "Add new" shows only to a user with create access on the terms collection. It is exported from the main entry and from ./taxonomy; tag any other content from the same taxonomy with it (createResourcesBlock({ facets }) in payload-blocks does). Only name, label and collection are read.

The list view is @bison-lab/payload-core/admin#QuestionsList. It wraps Page from @bison-lab/admin-ui so the column is Payload's --gutter-h, the same inset Media and Pages use — not a mockup max-width. A later kit list (Media) wraps the same Page. Run payload generate:importmap and payload migrate:create. Add a Features extra { slug: "questions", label: "FAQ Library" } the same way Navigation is added.

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, the Pages slug lock fields, the Redirects collection, the brand-assets collection fields, and the FAQ Library / taxonomy fields are columns in every consuming site. A change to them is a schema change: say "run payload migrate:create" in the changeset.