Docs

This page isn't translated yet

next

Posts module

Enable the module, its collections, featured media, the editing screen, SEO and the content editor.

On this page

Systhema ships an optional Posts module — a reusable blog/posts capability for @systhemaui/payload, with matching presentational components in @systhemaui/react / @systhemaui/next. It adds Posts, Categories, and Tags collections, a WordPress-style permalink engine, a post-template registry that mirrors page templates, an editor-created Archive PAGE template, a posts Lexical block, share-to-network buttons, and two extra built-in roles (Publisher / Author).

The module is disabled by default. Everything it adds — collections, capabilities, roles, the General Settings → Posts tab, the sitemap resolver, the routing branch — only exists when you turn it on, so projects that don't blog pay nothing for it.

At a glanceLink to this section

  • Disabled by default. Enable with posts: true (or a PostsOptions object) in withSysthema(). undefined ⇒ disabled.
  • Three collections. Posts (drafts + autosave + scheduled publish), and the toggleable Categories / Tags taxonomy collections. Author is a relationship to the existing users collection — no separate Authors collection. All three share a Posts admin sidebar group so they read as a distinct module (override with a standard collection admin.group override).
  • Independent type + template. A postType taxonomy select (for filtering/archives) and a postTemplate rendering registry (mirrors customPageTemplates) are chosen independently on each post. Each registered post template contributes a conditional field group.
  • WordPress-style permalinks. A configurable pattern (/posts/{slug} by default) drives single-post URLs. Posts slot into the SysthemaPage resolution chain after pages — pages always win at an exact path.
  • Editor-made archives. /blog, /reviews, /videos are real Pages using the built-in Archive PAGE template. No auto-generated archive routes.
  • One listing component. Latest posts, archive listings, related posts, and the posts block all render through the single PostsList component (@systhemaui/react).
  • Share + related, editor-controlled. Share-to-network buttons and the related-posts strategy are configured in General Settings → Posts and overridable per post.
  • Two extra roles. Publisher (full posts + taxonomies + publish) and Author (write own posts + SEO, no publish/delete) register only when the module is on.

Enabling itLink to this section

src/payload.config.ts
import { withSysthema } from '@systhemaui/payload'

export default buildConfig(
  withSysthema(
    {
      posts: true, // shorthand for { enabled: true }
    },
    { /* your base Payload config */ },
  ),
)

The object form gives finer control:

posts: {
  enabled: true,
  categories: true,                 // register the Categories collection (default true)
  tags: true,                       // register the Tags collection (default true)
  permalink: '/{category}/{slug}',  // code-level default; editors can override in General Settings
  types: ['Article', 'Video'],      // post types (default false). Titles → kebab slugs (article, video)
                                    // — or [{ slug, title }] for explicit slugs.
  templates: [/* SysthemaPostTemplate[] — see "Custom post templates" */],
  share: {
    enabled: true,
    networks: ['facebook', 'x', 'linkedin', 'nativeShare'],
    position: 'inline',   // 'inline' | 'sticky-bottom' | 'floating-sidebar'
    style: 'icon',        // 'icon' | 'icon-label' | 'pills'
  },
}

PostsOptions:

OptionTypeDefaultPurpose
enabledbooleanfalseTurn the module on. posts: true is shorthand for { enabled: true }.
categoriesbooleantrueRegister the Categories taxonomy collection.
tagsbooleantrueRegister the Tags taxonomy collection.
permalinkstring'/posts/{slug}'Code-level permalink default. The editable value lives in General Settings.
typesfalse | string[] | { slug, title }[]falsePost types for the postType select. Title array → kebab-cased slugs; or explicit { slug, title }, where title accepts a Payload StaticLabel for the postType select option's label in the editor's Admin language (Admin-only, never rendered on the front end). false/single type hides the select.
templatesSysthemaPostTemplate[][]Custom post templates (merged with the top-level customPostTemplates).
share{ enabled?, networks?, position?, style? }{ enabled: true, networks: […], position: 'inline', style: 'icon' }Code-level share defaults (incl. bar position inline/sticky-bottom/floating-sidebar + button style icon/icon-label/pills). The editable values live in General Settings → Posts → Share buttons.
media{ requirement?, placeholder? }{ requirement: 'recommended' }Featured-media requirement model + placeholder. See Featured media.

The full option shape is { enabled?, categories?, tags?, permalink?, types?, templates?, contentEditor?, share?, media?, listing?, hero?, post?, templateSlots? }. undefined ⇒ disabled (mirrors redirect); an object form is on unless enabled: false. types (default false) accepts a title array (slugs kebab-cased automatically) or { slug, title }[]; an explicit title accepts a Payload StaticLabel, resolved as the postType select option's label in the editor's chosen Admin language — it is Admin-only and never rendered on the front end. The last five configure the presentation/island/media model. post holds two single-post layout knobs — readingWidth (the body reading-column max-width) and bylineAvatarSize (the byline avatar size); both are plain CSS lengths applied as custom properties on #post and default to the current layout (zero regression). Exported types: PostsOptions, PostLayoutConfig, PostTypeInput ({ slug, title }), PostTypeDef (resolved { value, label }), SysthemaPostTemplate.

CollectionsLink to this section

  • Posts — drafts + autosave + scheduled publish. Fields: title, a tabs group (per-template Content field groups + an SEO meta tab gated on posts.seo.update), postType / postTemplate selects, category (→ categories), tags (→ tags, hasMany), author (→ users), publishedAt, featuredImage, excerpt, relatedPosts (self-relation), and a unique permalink-aware slug. Access: capabilityOrPublished('posts.read'), requireCapability('posts.create'), requireUpdateOrScoped('posts'), requireCapability('posts.delete').
  • Categories / Tags — toggleable taxonomy collections (name, slug, Categories also has description). Access mirrors Posts via publicOrCapability.

Author is a relationship to the existing users collection — there is no separate Authors collection. Posts + taxonomies sit right after pages in the default collection order.

Each post has a Featured image upload field — the card / social-share thumbnail, distinct from the hero (the in-page banner). It's optional: when a post has no featured image, the resolved media falls back to the post hero image (when the hero uses an image), then a placeholder, then nothing. The posts.media option controls how strictly it's treated and supplies the placeholder for the in-between case:

posts: {
  enabled: true,
  media: {
    requirement: 'recommended', // 'required' | 'recommended' | 'optional'
    placeholder: '/images/post-fallback.jpg', // image URL or path
  },
}
requirementField is required?Missing-media behaviourOG / social-share thumbnail
requiredYesEditors must upload an image to save.Always the post's own image.
recommendedNo (default)Falls back to the hero image, then the configured placeholder, in cards/hero/highlight.Post image → hero image → placeholder.
optionalNoFalls back to the hero image; no placeholder is injected if there's none.Post image → hero image → global default OG.
  • Resolution chain. The card / listing media resolves as featured image → post hero image (when it's an image) → placeholder (unless optional) → none. The hero image is read from the active template's hero field group, so a post with a banner hero but no featured image still shows that hero in its cards.
  • OG fallback. generatePostMetadata resolves the OpenGraph/Twitter image as post SEO image → featured image → hero image → placeholder → site-default OG. So required posts always have a thumbnail, and the hero image stands in before the placeholder.
  • Placeholder in rendering. When a post has no featured AND no hero image, and the requirement is required/recommended with a placeholder set, the resolved PostView.media carries the placeholder URL with media.isPlaceholder: true, so cards/hero/highlight render it and a Tier-2 island can tell a real image apart from the fallback. Under optional (or with no placeholder configured) media is null.
  • The field's admin description reflects the requirement (e.g. recommended → "Optional — … falls back to the post hero image, then a placeholder…").

General Settings Posts tabLink to this section

When posts is enabled, a Posts tab is inserted right after the Pages tab: permalink pattern (field-level guarded by global.general-settings.posts.update), showAuthor / showDate byline toggles, a share group (enabled + networks multiselect + a position select — inline / sticky-bottom / floating-sidebar — and a style select — icon / icon-label / pills), and a related group (strategy — manual-then-auto / manual-only / auto-only; autoSource — both / tags / category; limit, default 3) that drives resolveRelatedPosts. The posts-option permalink / share act as code-level defaults; these fields are the runtime source of truth. The related defaults reproduce the original hardcoded behavior, so an un-configured project is unchanged.

The post editing screenLink to this section

The Posts edit view mirrors Pages — a title field, a Content / SEO tab pair, and a sidebar:

  • Content tab — the per-template content (the default template's hero + content body), then Related posts at the bottom. Related posts is a top-level field rendered here because it's core to every post regardless of template.
  • SEO tab — present only when the seo feature is enabled, identical to the Pages SEO tab (Meta Title, Meta Description, Social Media Share Image, search-visibility toggle, live preview snippet). See SEO metadata for the fallback behaviour.
  • Sidebar, in order: URL path (the editable slug) followed by a live permalink preview that shows the final URL with the active pattern's tokens resolved from the post's own slug / category / date (e.g. domain/reviews/2026/my-post), updating as you edit; Excerpt, Featured image (the optional card thumbnail — see Featured media), Published date, Author, Category, Tags, Post Type + Post Template (each hidden when only one option exists). Category and Tags create as you type: when no existing category or tag matches the typed name (ignoring case), the dropdown offers Create "…", and picking it creates the document and selects it, with no drawer and no separate save. Existing matches stay first, so Enter picks one of them. The input keeps focus, so you can type the next tag straight away. In Tags, a comma also commits the typed name, and pasting a comma- or line-separated list selects every tag in it, creating the missing ones. Editors without the categories.create / tags.create capability get the plain select. There's no per-post "featured" flag — a listing's lead/highlight is simply its first post (leadCount). The preview is a read-only UI field (permalinkPreviewField) that fetches the resolved pattern from /sys/permalink-pattern and builds the URL with the same buildPermalink engine the server uses.

SEO metadataLink to this section

generatePostMetadata builds a post's <title>, description, OpenGraph, and Twitter tags with graceful fallbacks — nothing in the SEO tab is mandatory:

TagFallback chain
TitleSEO Meta Title → post title → website name
DescriptionSEO Meta Description → excerpt → the post body's plain text (whitespace-collapsed, word-boundary truncated) → site SEO global description
OG / Twitter imageSEO Social Share Image → featured image → post hero image → configured media.placeholder → site-default OG image → (none — the tag is dropped)

Posts go through the same site title template as pages: General Settings → SEO's Title template, Title separator and Website name (falling back to the seo.defaults plugin option) are applied to the post title, so an article ships Ten ways to ship faster – Acme, not a bare headline. A post that sets its own SEO Meta Title keeps it verbatim, exactly like a page. Change the template once and every page and post follows.

The OG image chain ties into the media requirement: a required post always has a thumbnail, and a recommended post falls back to the placeholder before the global default. The body-text description fallback reads the resolved template body (postData[postTemplate].content) via lexicalToPlainText, so even a post with no excerpt gets a meaningful <meta name="description">.

The post content editorLink to this section

The default post template's body (content) field uses the region-level Lexical editor by default — paragraph / heading / list + all inline blocks (button, chip, icon, avatar) + the standard region blocks (image, video, youtube, carousel, accordion, quote, embed, form, stack) + card, columns and gallery, all gated by which blocks.* you've enabled. It's narrower than the page root editor (which also offers the page-layout Section / Feature / Posts / Components blocks).

A Gallery in a post body renders as a full-width band of the article, the same as on a page: its content and slides sit in the design-system container, and readingWidth does not narrow it.

Swap it per project with posts.contentEditor:

withSysthema(payloadConfig, {
  posts: {
    enabled: true,
    // A level string — 'root' | 'region' (default) | 'fragment' | 'slot':
    contentEditor: 'root', // give posts the full page editor
  },
})

Or pass a fully custom editor — e.g. to drop card/columns/gallery, or to add posts-specific custom blocks without affecting the region editor used elsewhere (Section bodies, etc.):

import { regionLexicalEditor } from '@systhemaui/payload' // or build your own with lexicalEditor({...})

withSysthema(payloadConfig, {
  posts: { enabled: true, contentEditor: regionLexicalEditor({ withCard: true, withColumns: true }) }, // no gallery
})

The editor factories are part of the public API — regionLexicalEditor, rootLexicalEditor, fragmentLexicalEditor, slotLexicalEditor (and withLazyMount / richTextField) all import from @systhemaui/payload. Use regionLexicalEditor() for a custom post template's body rather than a bare lexicalEditor() — the latter ships Payload's full, unscoped feature set (tables, all heading levels, etc.) instead of the Systhema region set.

This only swaps the default post template's body. Custom post templates set their own content editor (and ignore posts.contentEditor) — reuse postBodyField from @systhemaui/payload if you want the default body in a custom template.