Docs

This page isn't translated yet

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.

  • href is the post's URL, label the text above the title (Payload uses the category name), and publishedAt an ISO date that the cards format in locale (en-US by default).
  • author is the public byline only. Payload never puts an email in it.
  • media is null when the post has no image. When Payload substitutes a configured placeholder image, isPlaceholder is true.
  • layout (grid2, grid3, grid4 or row) is set on cards by PostsList; it is absent on a single-post hero.
  • config carries the resolved show and classNames maps for the unit, so an island can honour them.
  • doc is the full source document, for an island that needs a field the view does not surface. Payload builds a PostView from a post with mapPostToView; 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): string

Merges 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): boolean

A 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): string

The 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): string

The 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 | null

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

HelperReturns
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) // 12

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