Posts view model and helpers
PostView, part resolution and listing helpers (sizesForLayout, splitGridItems, listing*).
On this page
The posts components share one data shape, PostView, and a handful of pure helpers. Use them to feed the components from any data source, and to make a custom card (an island) behave exactly like the default one. Everything on this page is exported from @systhemaui/react and @systhemaui/next, and none of it depends on Payload or Next.js.
import {
defaultMediaAspect,
formatPostDate,
listingPageCount,
listingPageOffset,
postByline,
resolvePartClassName,
showPart,
sizesForLayout,
splitGridItems,
type PostView,
} from '@systhemaui/next'PostViewLink to this section
interface PostView {
id: string | number
title: string
href: string
excerpt?: string
label?: string
category?: { name: string; slug: string } | null
tags: { name: string; slug: string }[]
postType: string
author?: { name: string; avatar?: { url: string; alt?: string }; role?: string } | null
publishedAt?: string
locale?: string
media?: {
url: string
alt?: string
width?: number
height?: number
srcset?: string
isPlaceholder?: boolean
} | null
layout?: PostLayout
config?: ResolvedUnitConfig
doc: any
}The resolved, serializable data for one post. Every posts component and every island takes it as post. Because it holds only plain data, it crosses the server-to-client boundary as a prop.
hrefis the post's URL,labelthe text above the title (Payload uses the category name), andpublishedAtan ISO date that the cards format inlocale(en-US by default).authoris the public byline only. Payload never puts an email in it.mediaisnullwhen the post has no image. When Payload substitutes a configured placeholder image,isPlaceholderistrue.layout(grid2,grid3,grid4orrow) is set on cards byPostsList; it is absent on a single-post hero.configcarries the resolvedshowandclassNamesmaps for the unit, so an island can honour them.docis the full source document, for an island that needs a field the view does not surface. Payload builds aPostViewfrom a post withmapPostToView; see Customizing the presentation.
Related types: PostLayout, ResolvedUnitConfig, the island types PostCardComponent, PostHeroComponent and PostSlotComponent (all ComponentType<{ post: PostView }>), the config shapes CardConfig, HighlightCardConfig and PostHeroConfig ({ show?, classNames?, island? }), and PostLayoutConfig ({ readingWidth?, bylineAvatarSize? }, see Single-post layout).
Part-resolution helpersLink to this section
The default cards resolve each part (media, label, title and so on) with these helpers. Use them in an island to get the same defaults and override behaviour.
resolvePartClassNameLink to this section
resolvePartClassName(defaultClassName: string, override?: string): stringMerges an override over a part's default classes with tailwind-merge: a conflicting utility in the override wins ('aspect-square' replaces 'aspect-[4/3]') and other classes are added.
showPartLink to this section
showPart(show: Partial<Record<string, boolean>> | undefined, part: string, fallback: boolean): booleanA part shows unless show[part] is false; a missing entry keeps fallback. So a show map only lists what differs from the defaults.
defaultMediaAspectLink to this section
defaultMediaAspect(layout: PostLayout | undefined): stringThe default media aspect classes for a card layout: 'aspect-[4/3] md:aspect-video' for grid2, 'aspect-[4/3] md:aspect-[3/2]' for row, and 'aspect-[4/3]' otherwise.
sizesForLayoutLink to this section
sizesForLayout(layout: string | null | undefined): stringThe sizes attribute for a card image in a layout. Every layout is 100vw up to 768px; above that grid2 and row are 50vw, grid4 is 25vw, and grid3 (and anything else) 33vw. The Next.js PostsList and PostCard pass it to next/image for you.
<Image src={post.media.url} alt="" fill sizes={sizesForLayout(post.layout)} />postByline and formatPostDateLink to this section
postByline(post: Pick<PostView, 'author' | 'publishedAt' | 'locale'>): {
author: string | null
date: string | null
dateTime: string | undefined
}
formatPostDate(iso: string | undefined, locale?: string): string | nullpostByline returns the props PostMeta needs. formatPostDate formats an ISO date as a long date in locale (default 'en-US', so '2026-09-14' becomes "September 14, 2026"), in UTC so the server and the browser agree on the day. It returns null for a missing or invalid date. PartConfig is the { show?, classNames? } type these helpers read.
splitGridItemsLink to this section
splitGridItems<T>(items: T[], leadCount?: number): { lead: T[]; tail: T[] }Splits a list into the lead grid and the tail, as PostsList does for leadCount. The split applies only when leadCount is positive and smaller than the list; otherwise everything is in lead and tail is empty.
splitGridItems(['a', 'b', 'c', 'd'], 2) // { lead: ['a', 'b'], tail: ['c', 'd'] }
splitGridItems(['a', 'b'], 5) // { lead: ['a', 'b'], tail: [] }Listing helpersLink to this section
A listing with a highlight card or a lead grid has uneven pages. limit (the "posts per page") counts the main list only; the highlight and the lead cards are extra on page 1. So page 1 holds limit + extra posts and every later page holds limit. These helpers do that arithmetic, so your fetchMore asks for the right slice. For a listing without extras (extra is 0) they reduce to plain page-based paging.
| Helper | Returns |
|---|---|
listingExtra(highlightFirst, leadCount) | The page-1 extras: 1 for the highlight plus leadCount (if > 0) |
listingPageOneCount(limit, extra) | Posts on page 1: limit + extra |
listingTotalPages(total, limit, extra) | The page count: 1 if everything fits on page 1, else 1 + the rest in pages of limit |
listingPageOffset(page, limit, extra) | The offset of a 1-based page: 0 for page 1, then limit + extra + (page - 2) * limit |
listingPageCount(page, limit, extra) | How many posts to fetch for a page: limit + extra on page 1, limit after |
With a highlight, a lead grid of 6 and 12 posts per page, 40 posts in total:
const extra = listingExtra(true, 6) // 7
listingPageOneCount(12, extra) // 19
listingTotalPages(40, 12, extra) // 3 (19, 12, 9)
listingPageOffset(2, 12, extra) // 19
listingPageCount(2, 12, extra) // 12A fetchMore built on them:
const extra = listingExtra(highlightFirst, leadCount)
async function fetchMore(page: number): Promise<PostView[]> {
const offset = listingPageOffset(page, limit, extra)
const count = listingPageCount(page, limit, extra)
const response = await fetch(`/api/journal?offset=${offset}&limit=${count}`)
return response.json()
}Payload's archive templates and the posts block use the same helpers. See Archive pages.
Next.jsLink to this section
Every helper and type on this page is re-exported unchanged from @systhemaui/next.