Archive pages
The archive page template, lead split, filters and building a custom archive.
On this page
There are no auto-generated archive routes. Archives are real Pages that an editor creates and assigns the built-in Archive PAGE template to. The Archive template renders as its own top-level editor tabs — Hero, Prepend, Posts, Append, and the page's shared SEO tab — instead of one "Content" tab (a page template can opt into this via editorTabs; the archive's fields therefore live at the page root, not under an archive group). The listing is effectively a posts block "sandwiched" between two root-editor regions.
A template that is configured by one or two choices rather than a tab's worth of fields declares them as sidebarFields: they sit in the document sidebar under the template select, appear only while that template is selected, and — like editorTabs fields — live at the page root, so prefix their names.
Template fieldsLink to this section
The template adds the following to the Page:
hero— a normal (optional) post hero, plus the archive's search/filter controls: asearchtoggle (default on), afiltersmultiselect (which taxonomy dropdowns to surface —categories/tags/types; defaultcategories+tags; empty = no filters; thetypesoption only appears on sites with more than one post type), and afilterStyleselect —Dropdowns(default) orChips— choosing how the taxonomy filters render (see Filter control style). When search is off and no filters are enabled the control renders nothing. Placement follows the hero type (see Search/filter placement).prepend— a root-level Lexical editor rendered above the listing (flexible sections/layouts before the posts).listing— how the unifiedPostsListis sourced and rendered. It is the same shared field-set as thepostsblock (createPostsListingFields), differing only in defaults:source(automatic query / fixed hand-picked), scoping filters (filterCategories/filterTags/filterPostTypes— the last shown only on multi-type sites),order+direction,limit/ Posts per page (the MAIN-list count — default 4 for the block, 12 for the archive; the highlight + lead grid are added on top of page 1, see Lead posts split),view/ Layout (grid2/grid3/grid4/row, defaultgrid4— the layout of the main list; the first N posts render above in their own layout when a lead split is set),loading(none/pagination/loadMore/infinite, defaultnone), the Row dividersdividerstoggle, plus an Advanced Settings drawer. The drawer holds Padding (range 0–6, default 0); the per-axis Items Gap X (gapX) and Items Gap Y (gapY) sliders — the inter-card column-gap / row-gap as generated gap-token indices, mirroring the Columns block (see Listing gap); the lead posts split controls Lead posts count (leadCount) + Lead posts layout (leadView, agrid2/grid3/grid4/rowselect shown only whenleadCount > 0); and Highlight first post (highlightFirst, defaultfalse) — all listing-level (block + archive). When Loading strategy is set to Load more button, the drawer also reveals a Load more button group (listing-level, block + archive) that configures the real design-system<Button>the listing renders for that strategy — Variant (any design-system button variant; unset = the default variant), Button title (prefilled "Load more"), Icon before the title + Icon after the title (icon-picker fields rendered around the title), and Alignment (Left/Center/Right, defaultCenter). Icons are resolved to HTML server-side and threaded toPostsList'sloadMoreButtonprop; the button carries the bespoke styling hook.posts-list-load-more(now appearance comes from the button tokens, so that class is just a target + the disabled affordance). On thepostsblock the drawer additionally exposes four block-only presentation controls: Media aspect ratio (21:9/16:9/4:3(default) /3:2/1:1/3:4/4:5/9:16— the ImageBlock fixed ratios; the responsive21/9-mdis excluded, since a card applies the ratio as a plain inlineaspect-ratio); for the Row layout only, Thumbnail width (Narrow/Default/Wide/Equal halves— the media | content column split) and Body alignment (Top/Center/Bottom— the body's vertical align against the taller media column, overriding the top-align default); and for grid + row, Media ↔ body gap (a gap-token slider — the gap between thumbnail and text inside a card, resolved to the matching--gap-*token, index 0 = none). All apply as inline styles so any value renders without a Tailwind content-scan, override the card defaults, and leave existing blocks untouched (omitted ⇒ no change). The listing also carries section presentation (theme/layoutBg) so it renders inside a themeable<Section>.append— a root-level Lexical editor rendered below the listing.
Lead posts splitLink to this section
A listing can render as a uniform single container (the default) or as a lead posts split: the first few posts fill a lead grid with its own layout, and the rest flow into the primary view container below. Two admin fields (both in Advanced Settings) drive it:
- Lead posts count (
leadCount, number) — render the first N posts after anyhighlightFirsthero in their own Lead posts layout (leadView); the remaining posts render below in the mainviewcontainer. Unset /0/ a value>=the item count ⇒ uniform single-container behaviour (no split). - Lead posts layout (
leadView, select —grid2/grid3/grid4/row, default'grid2') — the layout of the lead posts. Shown in the admin only whenleadCount > 0. The rest use the main Layout (view). - Row dividers (
dividers, checkbox, defaultfalse) — see below. Shown in the admin only when arowcontainer is actually rendered (the mainviewisrow, or a lead split uses arowleadView).
Count model — the page size adds up. limit (Posts per page) is the main-list count, NOT the total. The highlight hero (1) and the lead grid (leadCount) are added on top of page 1 automatically — so Posts per page: 12 with highlightFirst + a 6-card lead renders 19 on page 1 (1 + 6 + 12) and 12 on every later page (a uniform main grid — the highlight + lead are a page-1 treatment). The frontend computes every fetch count, so an editor never subtracts the highlight/lead from the page size by hand. Pages are therefore uneven: totalPages = 1 + ceil((total − pageOne) / limit) where pageOne = limit + 1 + leadCount. The pure helpers listingExtra / listingPageOneCount / listingTotalPages / listingPageOffset / listingPageCount (@systhemaui/react) encode this model and back both the server fetch (resolveArchiveData page 1, resolvePostsPage pages 2+ via an explicit fetch offset) and the client. An un-split listing (leadCount 0, no highlight) has extra = 0, so the model reduces to classic page-based pagination (byte-identical).
Row dividers (dividers, boolean, default false) draws a 1px --color-separator-line hairline between adjacent rows in the row (and split lead row) container via PostsList's <Stack divider> — the Figma archive list shape. Grids are unaffected (a row hairline doesn't apply across columns). resolveArchiveData reads it off the listing config (listing.dividers) and threads it through ArchiveData → PostsListClient → PostsList, and a Tier-2 custom archive can pass dividers to PostsList / PostsListClient directly. It is surfaced as a Row dividers checkbox in the admin — in both the shipped Archive template's Posts tab (the listing group) and the posts block.
resolveArchiveData resolves these into ArchiveData (leadCount?, leadView?, dividers?) and PostsListClient threads them into PostsList. Load-more pages append to the main view container automatically.
Per-section card config. Because the lead posts and the main list can be different layouts, each section gets its own layout's Tier-1 card config: the lead cards resolve cards[leadView] and the main cards resolve cards[view]. So with cards.grid2 = { aspectRatio: '16/9' } and cards.row = { aspectRatio: '4/3', bodyAlign: 'center' }, a grid2 lead + row main list render each section with its respective knobs. resolveArchiveData returns both cardConfig (the main view) and leadCardConfig (the lead leadView); PostsList applies leadCardConfig to the lead grid, falling back to cardConfig when unset (un-split listings are unaffected). A Tier-2 custom archive can pass both props to PostsList / PostsListClient directly.
A recommended split (a Figma example): limit: 12, highlightFirst: true, leadCount: 6, view: 'row', leadView: 'grid3', loading: 'loadMore' → page 1 = 1 hero + a 6-card grid lead + a 12-row list (19 posts), and Load more appends 12 more rows at a time.
The Archive template registers automatically as a PAGE template when posts is enabled (so it appears in the Page pageTemplate select). To build a /reviews archive: create a Page with slug reviews, pick the Archive template, and constrain the listing to the Reviews category. The archiveTemplateFields shape is exported for reuse/extension.
Search/filter placementLink to this section
Where the search/filter control mounts depends on the hero type — so it always reads as part of the page header regardless of the hero you pick:
| Hero type | Placement |
|---|---|
simple / feature | inside the hero (its append/content slot) |
background | a standalone <Section> below the hero, above the listing |
none | a standalone <Section> above the listing |
If search is off and filters is empty, nothing renders — no empty hero slot and no empty section. The control itself (search input + taxonomy pills) is the same ArchiveFilterControl everywhere; only its mount point changes.
Filter control styleLink to this section
The taxonomy filters render one of two ways, chosen by the hero's filterStyle select (only shown when at least one taxonomy filters dimension is enabled):
filterStyle | Rendering |
|---|---|
dropdown (default) | One <select> dropdown per taxonomy (the Figma archive shape). No regression — this is the prior behaviour. |
chips | Each taxonomy renders as a wrap row of toggle Chips (the podcasts wireframe). |
Both styles share the same filter state, handlers, and URL sync — filterStyle is purely presentational. The free-text search input is unaffected. In chips mode, clicking a chip selects that filter (and aria-pressed="true" + the .archive-filter-chip-toggle-active class mark it active); clicking the active chip again clears it. The removable active-filter chips row renders in both styles. resolveArchiveData normalizes the hero choice onto ArchiveData.filterStyle (defaulting unknown / unset values to 'dropdown') and threads it through ArchiveFilterControl → ArchiveSearchFilter's filterStyle prop; a Tier-2 custom archive can pass filterStyle to ArchiveFilterControl / ArchiveSearchFilter directly.
Listing gapLink to this section
The inter-card gap is a generated gap token, not a raw pixel value — the same mechanism Columns and Stack use. Two per-axis sliders in Advanced Settings — Items Gap X (gapX, column-gap) and Items Gap Y (gapY, row-gap) — each store an index into the design system's gap token set (none … 3xl), mirroring the Columns block's Horizontal/Vertical Gap. resolveArchiveData resolves each index to a GapSize and threads it to PostsList's gapX / gapY props, which apply an inline column-gap / row-gap (var(--gap-{size})). When a slider is left at the design default (or omitted on existing data) the per-axis core-CSS defaults apply: grids keep their asymmetric column sm / row md gaps, and the .posts-list-row flex container carries a default md row gap. In the row layout the card body is top-aligned against the (taller) media column by default (the kicker tracks the media top, matching the Figma row card); re-align it with the posts block's Body alignment control (Top / Center / Bottom — see the block-only presentation controls) or, in code, with custom CSS on the .post-card-row .post-card-body hook.
The Pagination loading mode renders the controls as ← prev · n / total · next → (centered), reusing the Gallery component's button + number font styling (masked-icon arrows). It scrolls to the top of the listing section rather than the top of the page on page change, and the highlighted first post is page-1 only — navigated pages render a uniform grid with no highlight.
Building a custom archive (search + load-more + ads together)Link to this section
When the shipped Archive template isn't enough — e.g. you want the search/filter control and interleaved ads in one listing — compose your own from the exported pieces. @systhemaui/payload/next exports the full filter pipeline:
resolveArchiveData(payload, { listing, … })— server resolver (page-1 query + authors + filter options + Tier-1/2 config).resolvePostAuthor(single, React-cached) /resolvePostAuthors(batched, no N+1 — use this for listings) — resolve a post's public byline as aResolvedPostAuthor({ name, avatar?, role? }) for custom bylines. They run underoverrideAccess, so the byline (includingrole, see the PostView byline above) is safe on anonymous published reads.authorRelationIdpulls the author relation id;AuthorRelationtypes the raw relation.ArchiveFilterProvider/useArchiveFilters— the shared client filter-state context.ArchiveFilterControl— the search + taxonomy-filter control, bound to that context. It forwardsorder/filterStyle/searchPlaceholder/filtersLabelto the underlyingArchiveSearchFilter:order?: readonly ArchiveFilterDimension[]reorders the taxonomy filters ('categories' | 'tags' | 'types'; default category → tag → type, omitted dimensions keep their default position after the listed ones),filterStyle?: 'dropdown' | 'chips'chooses dropdowns (default) or a toggle-chip row (see Filter control style),searchPlaceholder?overrides the search input placeholder (default'Search…'), andfiltersLabel?renders an optional leading eyebrow before the controls as.archive-filter-label(omitted when unset).PostsListClient— the client listing engine (load-more / pagination / infinite), which also accepts aninjectItemsrender-prop for interleaving ad nodes. It threadsleadCount/leadViewthrough toPostsList, so a custom archive gets the lead posts split too.splitGridItems(items, leadCount)— the pure helperPostsListuses to partition a flat list into{ lead, tail }. Also exported from@systhemaui/reactand@systhemaui/nextfor custom splitting.resolvePostsPage(payload, request)— the shared "filters →PostViewpage" resolver behind both SSR page 1 and the load-more / filter fetch. Resolves the public author withoverrideAccess, so an appended/filtered card keeps its byline. Use it to build a custom listing endpoint.
Recipe: resolve archiveData server-side, then wrap the subtree in ArchiveFilterProvider, mount ArchiveFilterControl (fed archiveData.filterOptions.*) in the hero, and render PostsListClient with injectItems={({ index }) => (index % 6 === 5 ? <YourAd /> : null)}. Search re-fetches page 1, load-more pages the filtered set, and ads interleave — all together. The filter control must live inside the same ArchiveFilterProvider subtree as the listing, and injectItems (a function) must be defined in a 'use client' component (it hands pre-rendered ad elements down — no closure crosses the RSC boundary).
Wrap the sections in <Article>. Like DefaultArchive (and DefaultPage), wrap your archive's <Section>s in <Article> (from @systhemaui/react/next), inside the ArchiveFilterProvider. The section-spacing relations — first/last-child padding suppression and the collapsing of py-section padding between adjacent sections (so neighbours don't double their gap) — are all CSS scoped under .article > section. A custom archive that renders bare <Section>s without the <Article> parent gets none of them, and the inter-section vertical rhythm will look wrong.
Interleaving ads (the injectItems context)Link to this section
injectItems receives a context that's been widened additively for the lead posts split — it stays fully backward-compatible:
injectItems?: (ctx: {
index: number
total: number
section: 'lead' | 'tail' // which container the call comes from
isLeadEnd: boolean // true ONLY for the single seam call between lead grid and tail list
}) => ReactNode- Per-card injection by
indexstill works exactly as before — return an element to drop an ad card after that card,nullto skip. Use it for in-list ad cadence (index % 6 === 5). sectiontells you whether the call comes from the lead grid or the tail list, so an ad can target one container.isLeadEndistruefor exactly one seam call fired between the lead grid and the tail list, only when a split is active. It renders as a sibling between the two containers (wrapped in.posts-list-interspace) — never inside the grid — so it's the place for a full-width ad at the grid→list seam:
injectItems={({ section, isLeadEnd }) => (section === 'lead' && isLeadEnd ? adNode : null)}