Customizing the presentation
The three tiers: config, TSX islands and optimized media, plus the frontend exports.
On this page
Every visible piece of the posts UI — each listing card, the highlighted lead card, and the single-post hero — is independently customizable along a three-tier spectrum. The same module serves a plain blog and a heavily art-directed magazine without forking components.
The three tiers (and how they win)Link to this section
| Tier | What it is | How you reach it | Code? |
|---|---|---|---|
| Tier 0 — Default | Figma-matching components (PostCard, HighlightCard, the default hero). | Nothing — it's what renders out of the box. | None |
| Tier 1 — Config | Per unit: toggle which parts render (show) + override the class of each part (classNames). | A plain object in posts.listing / posts.hero. | None |
| Tier 2 — Island | Replace the whole unit with your own TSX component. | An island (or templateSlots) component reference. | Yes |
Precedence is Tier 2 → Tier 1 → Tier 0. Per unit the three tiers collapse into one shape — { show?, classNames?, island? }:
- If you set an
island, it owns rendering. The Tier-1show/classNamesare not auto-applied (the module can't reach inside your TSX) — but the resolved config still travels on thePostViewthe island receives (post.config), so an island MAY honor your toggles if it wants to. - If you set only
show/classNames(Tier 1), the default component renders and applies them. - If you set nothing, the default renders with the Figma defaults (Tier 0).
Base styling (the blocks.posts CSS gate)Link to this section
The Tier-0 components ship their visual styling as a design-system CSS layer in @systhemaui/core (the .post-card*, .post-highlight*, .post-meta*, .posts-list*, .archive-filter*, and .post-share* hooks). The archive filter is composed from design-system primitives — a SearchField for search, FormSelect dropdowns per taxonomy, and removable active-filter chips — so it reuses the form/inputBlock + chip token surfaces and tracks your color system automatically.
This layer is gated behind blocks.posts in your systhema.config.ts and is on by default (like every other component block). To opt out — e.g. you fully art-direct the posts UI with Tier-2 islands and want none of the defaults — set:
blocks: { posts: false }Per-card scroll reveal. Each PostCard / HighlightCard carries the config animationClasses so posts reveal individually as they scroll into view, rather than the whole listing fading in at once. Cards in the same grid row cascade left to right, one var(--tw-stagger-delay, 120ms) step per column (below md every grid is a single column and no delay applies; the single-column row view is never staggered). Set --tw-stagger-delay to retune it — that is the same custom property the stagger-* utility reads. Disable per card with the disableAnimation prop (also accepted by PostsList, which threads it to every card).
Colour. The default card follows the active color system: the title carries color-heading, the excerpt color-body, and the card root is anchored to --color-typography-body so the kicker and byline (both currentColor-derived) mute against the right base. Drop a listing into a themed <Section> and its text follows that theme; a classNames.title / classNames.excerpt Tailwind text colour still wins.
Tier 1 — config (no code)Link to this section
Configure presentation in the posts plugin option. Three namespaces — listing (the cards), hero (the single-post banner), and post (single-post layout knobs):
posts: {
enabled: true,
listing: {
cards: {
grid2: { show, classNames, island }, // 2-column grid card
grid3: { show, classNames, island }, // 3-column grid card (the default view)
grid4: { show, classNames, island }, // 4-column grid card
row: { show, classNames, island }, // horizontal "row" card
},
highlightCard: { show, classNames, island }, // the full-bleed lead card
},
hero: { show, classNames, island }, // the single-post hero
post: { readingWidth, bylineAvatarSize }, // single-post layout knobs (see below)
templateSlots: { beforeContent, afterContent }, // Tier-2 only (see below)
}show — toggle which parts render. A part is ON unless you set it to false, so show is purely subtractive/additive over the documented defaults. The exact part keys per unit:
| Unit | Part keys (show / classNames) | Default-ON | Default-OFF |
|---|---|---|---|
card (grid2/grid3/grid4/row) | media, label, title, author, date, excerpt, readMore | media, label, title, author, date, excerpt | readMore (the whole card is the link) |
| highlightCard | media, overlay, label, title, author, date, excerpt, readMore | media, overlay, label, title, author, date, excerpt | readMore |
| hero | media, kicker, label, title, description, author, date, tags | media, kicker, label, title, description, tags | author / date follow General Settings (see below) |
Hero kicker + tags (taxonomy). The default post hero renders the post's category as a muted eyebrow kicker above the title (the .post-type.text-label treatment), and its tags as a <Chip> row alongside the byline — matching a standard article hero. Both default ON; turn either off with hero: { show: { kicker: false } } / { show: { tags: false } }, and restyle via hero: { classNames: { kicker, tags } }. General Settings has no separate category/tags toggle — the hero show is the off-switch. The kicker text + tag labels come from the same shared resolvers the listing cards use, so they read identically.
classNames — override the class of any part. Keys are the same part keys plus root (the unit's outer element); the highlight card also has overlay (the dark scrim). Each override is folded over the component's own default with twMerge, so your class wins on conflicting utilities instead of stacking:
grid3: { classNames: { media: 'aspect-square' } }
// the default `aspect-[4/3]` is DROPPED — twMerge resolves the conflict in your favour.Per-layout cards + the aspect defaults. The four card layouts (grid2/grid3/grid4/row) are configured (and islanded) independently — the active archive/listing view selects which one renders. Mobile is not a config target: it's the module's responsive transform of whichever desktop layout is active — always stacked (media over content), 4:3. The default media aspect per layout (the default value of classNames.media, overridable):
| Layout | Mobile | Desktop |
|---|---|---|
grid2 | 4:3 | 16:9 (aspect-[4/3] md:aspect-video) |
grid3 | 4:3 | 4:3 (aspect-[4/3]) |
grid4 | 4:3 | 4:3 |
row | 4:3 | 3:2 (aspect-[4/3] md:aspect-[3/2]) |
For the four card layouts the aspect is the default value of classNames.media, so a Tier-1 classNames.media override replaces it (twMerge). The highlightCard is 16:9 desktop, 4:3 mobile, but its aspect lives on the card root (the media fills the card), so override classNames.root — not classNames.media — to change it. In both cases, overriding with a single aspect-square collapses the base aspect but not a md: variant — to also change desktop, set the md: utility too (e.g. 'aspect-square md:aspect-square').
Copy-paste example — a 3-column grid with no excerpt, square media, and a smaller title:
posts: {
enabled: true,
listing: {
cards: {
grid3: {
show: { excerpt: false }, // hide the excerpt
classNames: {
media: 'aspect-square md:aspect-square', // 1:1 at every breakpoint
title: 'text-h5', // smaller heading
},
},
},
},
}Single-post layout (post)Link to this section
The post namespace holds two single-post layout measurements — the kind you'd otherwise hand-tune with a CSS override on every project. Both are plain CSS lengths and default to the module's current layout, so omitting them changes nothing (zero regression); set either to retune just that one measurement.
| Knob | What it sets | Default |
|---|---|---|
readingWidth | The post body's reading-column max-width — the measure the lexical body is centered within. | The article's container-derived width (container-width − 2·article-padding-x) — a full reading column, no extra narrowing. |
bylineAvatarSize | The byline avatar size (applied to both its width and height). | 1.75em (sized relative to the byline text). |
posts: {
enabled: true,
post: {
readingWidth: '773px', // narrow the body to a comfortable reading measure
bylineAvatarSize: '64px', // a larger byline avatar
},
}Both apply as CSS custom properties (--post-reading-width / --post-byline-avatar-size) on the single-post shell (#post), consumed by the blocks.posts core CSS with the defaults above as fallbacks. The reading-width constrains the same reading-flow elements the <Article> already centers (paragraphs, headings, lists, inline figures) — full-bleed sections, figure-w-full / -screen / -container media, and cards/columns/stacks stay edge-to-edge. Accepts any CSS length ('48rem', '70ch', '4rem', …). A custom hero island or templateSlots is unaffected by readingWidth (it renders its own layout); the byline avatar size applies wherever the default byline renders.
Tier 2 — full TSX islandsLink to this section
When config isn't enough, replace the whole unit with your own component via island (cards/highlight/hero) or templateSlots (around the post body). Your component receives the resolved PostView and renders anything.
Registering an island — set it on the matching unit (component references, not closures):
import { withSysthema } from '@systhemaui/payload'
import MagazineCard from './posts/MagazineCard' // 'use client'
import PromoHero from './posts/PromoHero' // server or client
import RelatedDisclaimer from './posts/RelatedDisclaimer'
export default buildConfig(
withSysthema(
{
posts: {
enabled: true,
listing: {
cards: { grid3: { island: MagazineCard } }, // replace the 3-col card
highlightCard: { island: MagazineCard }, // (a highlight island fits here too)
},
hero: { island: PromoHero }, // replace the single-post hero
templateSlots: { afterContent: RelatedDisclaimer }, // inject after the body
},
},
baseConfig,
),
)The 'use client' ruleLink to this section
Why: the listing (PostsList) is a client component that augments the server-rendered first page (load-more, infinite scroll, pagination). Card/highlight islands are therefore threaded across the RSC boundary as component references — and only a 'use client' reference survives that server→client hand-off. A server closure passed down would throw "Functions cannot be passed directly to Client Components." The hero and template slots, by contrast, render inline in the server post template, so they never cross the boundary and have no such constraint.
The PostView an island receivesLink to this section
Every island (and every default component) receives the same resolved, serializable view-model as a single post prop. It's mapped server-side from a populated Post document (keeping the no-email public-byline policy and the permalink engine), so it crosses to client cards as plain data — no functions, no class instances.
interface PostView {
id: string | number
title: string
href: string // resolved permalink
excerpt?: string
label?: string // the category name shown as the card/hero label ("CATEGORY 01")
category?: { name: string; slug: string } | null
tags: { name: string; slug: string }[]
postType: string // e.g. 'article', 'video'
author?: { name: string; avatar?: { url: string; alt?: string }; role?: string } | null // public byline; `role` is the optional byline secondary label
publishedAt?: string // ISO; format it however you like
media?: {
url: string
alt?: string
width?: number
height?: number
srcset?: string
isPlaceholder?: boolean // true when `url` is the configured placeholder, not the post's own image
} | null
layout?: 'grid2' | 'grid3' | 'grid4' | 'row' // present for cards; lets one island branch per layout. Absent on the hero.
config?: { show?: Record<string, boolean>; classNames?: Record<string, string> } // the resolved Tier-1 config (an island MAY consult it)
doc: any // the full populated post document — typed `any` (react has no Payload dep); cast to your generated `Post` in your island. The escape hatch: read any field the view doesn't surface.
}media is null when the post has no image and no placeholder applies. When the requirement is required/recommended with a placeholder configured, media carries the placeholder marked isPlaceholder: true (see Featured media). The doc field is the escape hatch: read any custom field the resolved view doesn't surface.
author.role is the byline secondary label (e.g. "Editor", "Staff Writer"). It is derived publicly (under overrideAccess, so anonymous published reads still get it) from the first present, non-empty string among the author user's fields, in priority order position → role → jobTitle → title. The key is omitted when the user has none, so projects without such a field are unaffected. (The access-control capability roles array is deliberately not used — it's an access concept, not a public job title.) The default Single-Post template (DefaultPost) renders role after the author name inside the byline as <span className="post-meta-author-role">; islands and custom bylines can read view.author?.role and render it however they like.
A complete 'use client' card islandLink to this section
'use client'
import React from 'react'
import Link from 'next/link'
import Image from 'next/image'
import type { PostView } from '@systhemaui/react'
import { postByline } from '@systhemaui/react' // optional: the same byline helper the default uses
export default function MagazineCard({ post }: { post: PostView }) {
const { author, date, dateTime } = postByline(post)
// `post.layout` lets one island branch per layout (e.g. wider media on `row`).
const wide = post.layout === 'row'
return (
<Link href={post.href} className="magazine-card group block">
{post.media && (
<div className={wide ? 'aspect-video' : 'aspect-square'}>
<Image
src={post.media.url}
alt={post.media.alt ?? ''}
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover"
/>
</div>
)}
{post.label && <span className="text-label">{post.label}</span>}
<h3 className="text-h5 group-hover:underline">{post.title}</h3>
{(author || date) && (
<p className="text-label">
{author}
{author && date ? ' • ' : ''}
{date && <time dateTime={dateTime}>{date}</time>}
</p>
)}
</Link>
)
}The hero and templateSlots components have the same { post: PostView } signature; they just don't need the 'use client' directive (though it's harmless if you add it).
Optimized media: the next PostsList wrapperLink to this section
@systhemaui/react's PostsList is media-agnostic — given no render-prop it renders a plain <img>. @systhemaui/next ships a thin PostsList wrapper (not a bare re-export) that defaults renderImage / renderHighlightImage to next/image, so every listing (Latest Posts, Archive, Related Posts, the posts block) ships responsive, optimized media without you wiring next/image by hand:
- Grid/row cards render via
next/image(fill) with asizeshint derived from the listingviewthroughsizesForLayout(view)— so each column count downloads the rightsrcsetcandidate (grid4→25vw,grid3→33vw,grid2/row→50vw, all100vwon mobile) instead of over/under-fetching with one hardcoded hint. - The highlight (lead) card renders full-bleed (
sizes="100vw",priority) — it's the LCP candidate at the top of the listing. - A consumer-supplied
renderImage/renderHighlightImagealways wins; thenext/imagedefaults only fill the gaps. The whole prop surface (includingdividers) is forwarded unchanged, so it's a drop-in for the reactPostsList.
The built-in templates (DefaultArchive, DefaultPost's related grid, the posts block) already render through this next-optimized PostsList, so the optimized media is the default everywhere out of the box. sizesForLayout is exported from both @systhemaui/react and @systhemaui/next for islands that render their own next/image.
Stores and exported typesLink to this section
Island refs live in the separate postsPresentationStore (setPostsPresentation / getPostsPresentationConfig / getPostsPresentationIslands) so they never serialize — mirroring customBlocksStore. The featured-media model (posts.media: { requirement?, placeholder? }) lives in the store postsMediaStore (setPostsMediaConfig / getPostsMediaConfig).
Re-exported presentation/media types: PostsOptions, PostsPresentationConfig, PostsPresentationIslands, PostCardLayout, SerializableCardConfig, SerializableHighlightCardConfig, SerializablePostHeroConfig, SerializablePostLayoutConfig, PostsMediaConfig, PostsMediaRequirement, plus the consumer-facing config shapes CardConfig / HighlightCardConfig / PostHeroConfig / PostLayoutConfig (from @systhemaui/payload); the view-model + island component types (PostView, PostLayout, ResolvedUnitConfig, PostCardComponent, PostHeroComponent, PostSlotComponent) re-export from @systhemaui/payload/next (originally @systhemaui/react).
Frontend exportsLink to this section
The @systhemaui/payload/next entry exports the server-side posts surface — the archive filter pipeline (resolveArchiveData, PostsListClient, ArchiveFilterProvider / useArchiveFilters, ArchiveFilterControl), mapPostToView (Post document → serializable PostView), the built-in DefaultArchive / DefaultPost templates, and resolveRelatedPosts. See Building a custom archive for the composition recipe.
import {
resolveArchiveData,
resolvePostsPage,
resolveRelatedPosts,
PostsListClient,
ArchiveFilterProvider,
useArchiveFilters,
ArchiveFilterControl,
mapPostToView,
getPostTypeLabels,
resolvePostAuthor,
resolvePostAuthors,
authorRelationId,
DefaultArchive,
DefaultPost,
type ArchiveData,
type ArchiveFilterControlProps,
type PostsListClientProps,
type PostsRestFilters,
type PostsPageRequest,
type PostsPageResult,
type ResolvedPostAuthor,
type AuthorRelation,
type PostView,
type PostLayout,
type PostTypeLabels,
} from '@systhemaui/payload/next'-
resolveArchiveData(→ArchiveData) — resolves the full server-side archive listing from the page'slistingconfig: queries page 1 (or maps thefixedpicks), batch-resolves bylines (overrideAccess), maps each doc to aPostView, loads the full taxonomy pill options, resolves the section presentation (theme/layoutBg/padding) and thegaptoken (index →GapSize), normalizes the hero'sfilterStyle(dropdown/chips) ontoArchiveData.filterStyle, and threads the resolved Tier-1 config + Tier-2 island refs + media model down.DefaultArchivenarrows the pill options to the hero's enabledfiltersbefore handing them to the control. Filtering is client-driven via theArchiveSearchFiltercallback (catch-all archive routes are statically cached, so the resolver never readssearchParams). -
PostsListClient(PostsListClientProps,PostsRestFilters) — the client listing that powers Latest / Archive / Related / the posts block. Renders the lead-grid + tail-list split, drivesloading(none/pagination/loadMore/infinite), and posts to the/sys/posts-listroute for filtered/appended pages. Wrap it inArchiveFilterProviderand passinjectItemsto interleave ads at the grid→list seam. -
ArchiveFilterControl(ArchiveFilterControlProps) — the search + taxonomy-filter control bound to the filter context. Forwardsorder?: readonly ArchiveFilterDimension[],filterStyle?: ArchiveFilterStyle,searchPlaceholder?: string, andfiltersLabel?: stringto the underlyingArchiveSearchFilter:orderreorders the taxonomy filters (default category → tag → type; omitted dimensions keep their default position after the listed ones),filterStylechooses'dropdown'(default) vs a'chips'toggle row,searchPlaceholderoverrides the search input placeholder (default'Search…'), andfiltersLabelrenders an optional leading eyebrow as.archive-filter-label(omitted when unset). -
resolvePostsPage(PostsPageRequest→PostsPageResult) — the shared "filters →PostViewpage" resolver behind both SSR page 1 and the load-more / filter fetch. RunsqueryPosts+ the batched,overrideAccessauthor resolution +mapPostToView, so a load-more page keeps its byline (name + avatar) where a client map of the access-gated/api/postsresponse would drop it. Backs the module's/sys/posts-listroute (handlePostsList, mounted viasysthemaApiRoutes);PostsListClientposts to it automatically and falls back to/api/postswhen it's not mounted. -
resolvePostAuthor(single, React-cached) /resolvePostAuthors(batched, no N+1 — for listings) — resolve a post's public byline as aResolvedPostAuthorfor custom bylines. They run underoverrideAccess, so anonymous published reads still get the byline.authorRelationIdpulls the author relation id;AuthorRelationtypes the raw relation.interface ResolvedPostAuthor { name: string avatar?: { url: string; alt?: string | null } | null role?: string | null }roleis the byline secondary label (e.g. "Editor", "Staff Writer"), derived publicly from the first present, non-empty string among the author user's fields, in priority orderposition→role→jobTitle→title. Absent when the user has none, so projects without such a field are unaffected. (The access-control capabilityrolesarray is deliberately not used — it's an access concept, not a public job title.) See Posts → the byline secondary label.