---
title: "SEO settings"
description: "Title templates, code-defined defaults, the token-chip title editor, social previews and canonical URLs."
requested_language: ar
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/ar/next/payload/content/seo
version: unreleased (main)
docs_index: https://docs.systhema.app/ar/next/llms.txt
---
> This page isn't translated yet. Showing English.


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](#two-behavior-changes-from-the-standalone-global) below for the `generalSettings: false` + `seo: true` combination:

| Field            | Type                                            | Purpose                                                                                                                                                                          |
| ---------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `websiteName`    | text                                            | Site name used by title templates and as the standalone title for pages without a title of their own.                                                                            |
| `titleSeparator` | radio (`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. |
| `titleTemplate`  | text                                            | Template used to compose titles when a page has no custom SEO title.                                                                                                             |
| `image`          | upload (image)                                  | Default social-media share image (optimal size 1200×630px), used when the page doesn't override it.                                                                              |
| `description`    | meta description                                | Default meta description (from `@payloadcms/plugin-seo`).                                                                                                                        |
| `preview`        | ui                                              | Multi-channel social preview — see [Multi-channel social preview](#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.

![General Settings SEO fields and title-template chips](./images/settings-seo.light.webp)

## Code-defined defaults (`seo.defaults`)

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:

```ts
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 editor

![The title-template editor with Page title, Separator and Website name variables](./images/title-template.light.webp)

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 preview

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 URLs

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

## Capability

`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](https://docs.systhema.app/ar/next/payload/access-control.md#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](https://docs.systhema.app/ar/next/payload/content/general-settings.md#capability) above.

## Upgrading from the SEO global

> [!NOTE]
> Changed in [v1.7.0](../../release-notes/v1.7.0.md): the SEO fields moved from a standalone `seo` global into the General Settings SEO tab.

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 global

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.
