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 aPostsOptionsobject) inwithSysthema().undefined⇒ disabled. - Three collections.
Posts(drafts + autosave + scheduled publish), and the toggleableCategories/Tagstaxonomy collections. Author is a relationship to the existinguserscollection — no separate Authors collection. All three share a Posts admin sidebar group so they read as a distinct module (override with a standard collectionadmin.groupoverride). - Independent type + template. A
postTypetaxonomy select (for filtering/archives) and apostTemplaterendering registry (mirrorscustomPageTemplates) 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 theSysthemaPageresolution chain after pages — pages always win at an exact path. - Editor-made archives.
/blog,/reviews,/videosare 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
postsblock all render through the singlePostsListcomponent (@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
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:
| Option | Type | Default | Purpose |
|---|---|---|---|
enabled | boolean | false | Turn the module on. posts: true is shorthand for { enabled: true }. |
categories | boolean | true | Register the Categories taxonomy collection. |
tags | boolean | true | Register the Tags taxonomy collection. |
permalink | string | '/posts/{slug}' | Code-level permalink default. The editable value lives in General Settings. |
types | false | string[] | { slug, title }[] | false | Post 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. |
templates | SysthemaPostTemplate[] | [] | 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-templateContentfield groups + an SEOmetatab gated onposts.seo.update),postType/postTemplateselects,category(→ categories),tags(→ tags, hasMany),author(→ users),publishedAt,featuredImage,excerpt,relatedPosts(self-relation), and a unique permalink-awareslug. Access:capabilityOrPublished('posts.read'),requireCapability('posts.create'),requireUpdateOrScoped('posts'),requireCapability('posts.delete').Categories/Tags— toggleable taxonomy collections (name,slug, Categories also hasdescription). Access mirrors Posts viapublicOrCapability.
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.
Featured mediaLink to this section
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
},
}requirement | Field is required? | Missing-media behaviour | OG / social-share thumbnail |
|---|---|---|---|
required | Yes | Editors must upload an image to save. | Always the post's own image. |
recommended | No (default) | Falls back to the hero image, then the configured placeholder, in cards/hero/highlight. | Post image → hero image → placeholder. |
optional | No | Falls 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'sherofield group, so a post with a banner hero but no featured image still shows that hero in its cards. - OG fallback.
generatePostMetadataresolves the OpenGraph/Twitter image as post SEO image → featured image → hero image →placeholder→ site-default OG. Sorequiredposts 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/recommendedwith aplaceholderset, the resolvedPostView.mediacarries the placeholder URL withmedia.isPlaceholder: true, so cards/hero/highlight render it and a Tier-2 island can tell a real image apart from the fallback. Underoptional(or with no placeholder configured)mediaisnull. - 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 +
contentbody), 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
seofeature 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 thecategories.create/tags.createcapability 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-patternand builds the URL with the samebuildPermalinkengine 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:
| Tag | Fallback chain |
|---|---|
| Title | SEO Meta Title → post title → website name |
| Description | SEO Meta Description → excerpt → the post body's plain text (whitespace-collapsed, word-boundary truncated) → site SEO global description |
| OG / Twitter image | SEO 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.