Docs
Systhema Design (opens in new tab)
Unreleased

SEO settings

Title templates, code-defined defaults, the token-chip title editor, social previews and canonical URLs.

On this page

The SEO tab appears after the Pages tab and, when enabled, the Posts tab in the general-settings global and carries the site-wide SEO defaults. It is registered when the seo plugin option is not disabled (seo: false) and the general-settings global itself is enabled — see Two behavior changes from the standalone global below for the generalSettings: false + seo: true combination:

FieldTypePurpose
websiteNametextSite name used by title templates and as the standalone title for pages without a title of their own.
titleSeparatorradio (iconGroupField, appearance: 'tiles')Separator character inserted by %title_separator%; defaults to an en dash (–). Rendered as a Yoast-style wrapping grid of tiles, the selected one marked with a check badge.
titleTemplatetextTemplate used to compose titles when a page has no custom SEO title.
imageupload (image)Default social-media share image (optimal size 1200×630px), used when the page doesn't override it.
descriptionmeta descriptionDefault meta description (from @payloadcms/plugin-seo).
previewuiMulti-channel social preview — see Multi-channel social preview below.

Title templates support three variables: %page_title% (the page's content title), %title_separator% (the character selected by titleSeparator), and %website_name% (the global websiteName). The default template is %page_title% %title_separator% %website_name%, producing titles such as About – Acme.

Title precedence is: a non-empty per-page SEO title overrides the global template (variables still work inside the override); otherwise a page with a content title uses titleTemplate; otherwise websiteName is used on its own. Whitespace gaps and dangling separators caused by empty variables are cleaned up; deliberately authored repeated spaces are preserved. The AI SEO generator still fills the per-page SEO title override, unchanged.

Code-defined defaults (seo.defaults)Link to this section

The seo plugin option also accepts an object form, letting you forward SEO defaults you've already defined in code — typically the same values used in your Next.js app/layout.tsx metadata export:

withSysthema(config, {
  seo: {
    // enabled: true, // default
    defaults: {
      websiteName: 'Acme',
      titleSeparator: 'pipe', // a TITLE_SEPARATORS key — see utils/seoTitle.ts
      titleTemplate: '%page_title% | %website_name%',
      description: 'Acme builds things.',
      image: 'https://acme.com/og-default.png', // absolute or root-relative URL
    },
  },
})

A boolean is still accepted (seo: true / seo: false) as shorthand for { enabled } with no defaults.

Fallback chain, applied per field, both at runtime and in the admin: the General Settings → SEO value (or, on a page, the page's own override) → the code-defined seo.defaults value → a built-in fallback (resolveSeoTitle's own DEFAULT_TITLE_TEMPLATE/DEFAULT_TITLE_SEPARATOR for the title fields; nothing rendered for description/image). This applies to generatePageMetadata's runtime <title>/<meta description>/OG+Twitter image, and to the admin: the SEO tab's websiteName field shows the code default as its placeholder when empty; the titleTemplate chip editor's multi-channel preview cards fall back to the code defaults live, before saving; the Pages collection's meta.title preview cards and length indicator fall back the same way. The description field can't take a native placeholder (its custom @payloadcms/plugin-seo renderer doesn't support one) — the multi-channel preview still shows the resolved fallback live.

Token-chip title editorLink to this section

Both titleTemplate (here, on the SEO tab) and the Pages collection's meta.title field (Pages → SEO tab, page-level override) render the SAME custom input — a Yoast-style chip editor (SeoTitleInput) instead of a plain text box, styled to match a native Payload text input at rest. Variables appear inline as small non-editable pills instead of raw %…% text:

  • Typing % opens a popover at the caret listing the three variables — arrow keys + Enter to insert, Escape to cancel, or click one directly.
  • The "Insert variable" button (a right-aligned suffix inside the input) toggles the same popover, inserting at the current caret position (or appending to the end if the field isn't focused).
  • Backspace/Delete beside a chip is a deliberate two-step: the first press selects the whole chip (highlighted as one atomic unit), the second deletes it. A selected chip can be typed over to replace it, or dismissed with Escape.
  • Arrow keys step onto a neighbouring chip (selecting it as one unit) and off its far side — the caret can never rest inside a pill. Alt/Option+Arrow word-jumps treat a chip as exactly one word: Alt+Left/Right land immediately before/after the pill, never beyond it.
  • Undo/redo (Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z, Ctrl+Y) is owned by the editor and behaves like a native input: chip insertions/deletions are discrete steps, consecutive typing coalesces, and the caret lands at the site of the (un)done edit.
  • Pasted text is sanitized to plain text, and any %page_title% / %title_separator% / %website_name% sequences in it become chips automatically. Copy/cut of a selection containing chips puts the %…% tokens on the clipboard, so chips survive a round-trip.
  • The field is still a single-line plain-text value under the hood (Enter never inserts a line break) — chips are a presentation layer over the same stored string, so nothing about the data shape changes.

Any field description that mentions a %variable% (here, websiteName/titleSeparator/titleTemplate, and the Pages meta.title description) renders the token as a small click-to-copy <code> chip (VariableDescription) instead of raw text — clicking, or pressing Enter/Space while it's focused, copies the exact %…% token to the clipboard with brief "Copied" feedback.

The RESOLVED title (variables expanded — on meta.title against the real page title and saved General Settings SEO values; on titleTemplate against a sample page title and the OTHER SEO-tab fields' live, unsaved values) feeds the multi-channel preview cards below the fields, which update live as you type. Only meta.title also shows a plugin-seo-style length indicator (a coloured pill — too short / good / too long — plus a progress bar) measuring the RESOLVED title's character count against the same 50–60 character range plugin-seo recommends for meta titles; an empty/undefined resolved title still shows the indicator at 0 characters ("Missing"), matching plugin-seo's own treatment of an empty field. titleTemplate has no fixed length to target, so it has no counter. Deliberate consecutive spaces in titles are preserved end-to-end (editor, resolved output, and previews); only the gaps left by variables that resolve to empty values are collapsed.

Multi-channel social previewLink to this section

The SEO tab's preview field (and the Pages collection's matching meta.preview field on its own SEO tab) render SeoSearchPreview — a channel-tab switcher (Google SERP, Facebook, X, LinkedIn, Discord, Slack; Google is the default tab, remembered per-browser via localStorage) over brand-accurate share-card mockups, adapted from the Systhema Design app's social-preview cards. On Pages, the cards use the page's own resolved title/description/share-image, falling back to these SEO-tab defaults when the page sets none. Here on the SEO tab itself there's no real page, so the cards run a sample title ("Your page title") through the OTHER SEO-tab fields' live, unsaved values — the same live-sibling pattern the title-template preview above uses — so tweaking the separator, website name, default description, or default image updates every card instantly, without saving first.

Canonical URLsLink to this section

generatePageMetadata emits a rel=canonical for every published page and post it resolves, pointing at that document's own public URL — /thank-you for the thank-you page, the permalink (/posts/hello-world) for a post, and / for the page configured as the homepage (never its own slug URL, e.g. /home). URLs are absolute when NEXT_PUBLIC_SERVER_URL is set and root-relative otherwise, resolved by Next against your metadataBase; with locale domains each locale's canonical uses its own origin.

Multilingual sites additionally get the full self-referencing hreflang set plus x-default alongside the canonical, built from each locale's own strict route key. Single-locale and locale-off sites get the canonical alone — no alternates.languages.

Because Next merges metadata key by key, a page's canonical always replaces whatever alternates.canonical your root layout.tsx declares (scaffolds ship alternates: { canonical: '/' }). Keep that root value as the fallback for routes Systhema doesn't own; every published Systhema page and post overrides it with its own URL. The one exception is a document with no published route in the rendered locale — a draft-mode preview of a page that was never published. Systhema emits no alternates there, so the preview URL inherits the root layout's literal canonical; those URLs are cookie-gated and never indexed.

CapabilityLink to this section

global.general-settings.seo.read (read the tab's fields) and global.general-settings.seo.update (edit them, implies read) — both registered whenever the tab is. Inherited by admin via global.*. The built-in seoManager role receives global.general-settings.seo.* (the read + update pair) and nothing else of General Settings; editor receives only global.general-settings.seo.read. See Access control → Built-in roles for the full per-tab capability table and the field-level read-guard rules. To grant either capability to a custom role, follow the same pattern as the Pages tab capability above.

Upgrading from the SEO globalLink to this section

Prior versions shipped these fields as a standalone seo global. Projects upgrading via systhema upgrade get their existing data copied into the new tab automatically — the CLI runs pnpm payload migrate-seo-to-general-settings (a Payload bin script registered by @systhemaui/payload) right after pnpm install. The legacy default title becomes Website Name; the separator and template use their runtime defaults when the migrated database columns are null. As a result, pages without a custom SEO title now get templated titles instead of only their page title — an intentional improvement. The script is idempotent: it no-ops if there's nothing in the old seo table/collection, and re-running after success reports "already migrated" without overwriting. Pass --force to overwrite the new tab from the old data. The old seo DB table/collection is left in place after migration — drop it manually if you want a clean schema.

On a project with content localization the destination is not the general_settings table: every SEO field the loaded config localizes lives in general_settings_locales (SQL) or as a { [locale]: value } record on the global document (Mongo). The script resolves that from the config it is handed — the same place its enabled gate reads — and writes the legacy values once per configured locale, leaving any genuinely shared field (today only titleSeparator) on the base table. It exits 1 if the locales table is missing, so push the schema before running it.

A companion codemod rewrites capability strings in your project source during the same upgrade: global.seo.read → global.general-settings.seo.read, global.seo.update → global.general-settings.seo.update, and the wildcard global.seo.* → global.general-settings.seo.* (a single string — patternMatches prefix-matches the deeper wildcard the same way it already does for pages.seo.*). Custom roles defined in non-.ts/.tsx files (JSON, YAML, DB rows) are NOT touched by the codemod — sweep them manually.

Users with explicit per-user capability overrides need pnpm payload migrate-capability-strings after PostgreSQL has added the replacement enum labels. Run it before an operator-owned enum recreate that removes legacy labels. It rewrites stored values idempotently and keeps users with both values on the replacement value once.

Two behavior changes from the standalone globalLink to this section

Moving SEO into General Settings changes two behaviors on purpose. Both are permanent, not migration hiccups:

  1. SEO defaults are no longer publicly readable over REST/GraphQL. The old standalone seo global used publicOrCapability('global.seo.read'), so an unauthenticated request could read it directly — useful for a decoupled frontend fetching site-wide defaults without a session. general-settings holds email/SMTP and cookie-consent configuration alongside SEO, so its read access stays capability-gated unconditionally; there is no public branch. If your frontend fetched the old seo global anonymously, switch to one of:
    • Local API from the server (Server Component, route handler, or build-time script) — payload.findGlobal({ slug: 'general-settings' }), which runs with full access and never goes through the public REST/GraphQL layer.
    • An authenticated request (a logged-in user or an API key) whose role/capabilities include global.general-settings.seo.read (or broader read access).
  2. generalSettings: false combined with seo: true (or the default) now means no SEO defaults UI at all, not "just the SEO global, standalone." Since the SEO tab lives inside general-settings, disabling the parent global drops every tab with it. withSysthema() logs a one-time startup warning when it detects this combination, pointing at enabling generalSettings. If you intentionally want SEO defaults without Pages/Emails/Cookie Consent, keep generalSettings enabled (the default) and rely on seo: true alone — there's no way to keep only the SEO tab while disabling the parent global.