Receiving Content
When you write a section type, the content prop arrives already parsed: the author's markdown has been read into named pieces — title, pretitle, subtitle, body fields, items — before your component runs. This guide is about consuming that structure well: what the parse promises you, which habits keep a component working across every document an author will ever feed it, and which habits quietly break.
The short version of the deal: the parser is defensive coding done once, upstream. You get to write render logic instead of text extraction — no scanning for headings, no null checks, no "what if the author wrote it in a different order." In exchange, you treat the named pieces as the interface and let go of assumptions about the markdown that produced them.
The full field-by-field shape is in the content structure reference. This guide is about how to think when consuming it.
The shape is guaranteed
Every content field exists on every section — empty string or empty array when the author wrote nothing there. That's a load-bearing guarantee, not a convenience: it means you destructure and map without ceremony.
From templates/services/foundation/sections/Testimonials/index.jsx:
export default function Testimonials({ content, params }) {
const { title, items } = content
return (
<div>
{title && <H2 text={title} className="…" />}
<div className={gridClass}>
{items.map((item, i) => (
<figure key={i}>
{item.paragraphs?.[0] && <P text={item.paragraphs[0]} />}
…
</figure>
))}
</div>
</div>
)
}items.map runs with no existence check because items is always an array. And the title && guard is doing less than it looks like: kit's text components self-erase — <H2 text="" /> renders null — so a bare component needs no guard at all. The guard earns its place only when a wrapper must disappear with the content (a header block with its own margin). Likewise item.paragraphs?.[0] above is a habit the guarantee makes unnecessary — paragraphs always exists, and as the next section shows, the whole conditional can collapse into one component call.
That's the distinction to hold onto: conditionals in a section type express design choices, never fear of missing fields. If you find yourself writing content?.items ?? [], the defensiveness is aimed at a case that cannot occur, and it will mislead the next reader into thinking it can.
What the guarantee does not cover is meaning. title exists but may be ''; an item may carry only an icon. Authors legitimately write sparse content, so a component should degrade by design — decide what a card without a title looks like, rather than assuming one.
Headline slots hold lines, not strings
title, pretitle, and subtitle are each a string — or an array of strings when the part spans several lines. All three, and this is by authorial design, not edge case:
- A split headline:
title→["Build the future", "with confidence"] - A three-line header (name / role / affiliation, an event's dates / venue):
subtitle→["Chief Scientist", "Analytical Engines Ltd"] - A stacked label:
pretitle→["Acme Labs", "Announcing"]
The habit that makes this free: pass content fields to the kit text components — Text and its aliases H1–H6, P, Span, Div, PlainText (kit/src/components/Text/Text.jsx). They speak the parsed shape natively, which is worth understanding once because it deletes code from every component you write:
- String or array, one call.
<H1 text={content.title} />renders a string as the tag, and an array as one heading with each line in its own inner element — the multi-line headline, no branch on your side. - Non-heading tags map the array instead.
<P text={content.paragraphs} />renders every paragraph, one<p>per entry, each carrying yourclassName. Theparagraphs?.[0] &&conditional from the Testimonials excerpt above is one line:<P text={item.paragraphs} className="text-body italic" />. - Empty content self-erases. Empty strings, whitespace, and empty array lines render
null— the components absorb the sparse-content tolerance for you. - Inline markup and links come out right. Parsed text carries HTML —
<strong>, accent spans from[text]{accent}, anchors — and the components render it and run authored hrefs through the same resolution chain structured<Link>s get:page:references, locale prefixes, the deployment base path. lineAscontrols the per-line tag when the default (divinside headings,potherwise) isn't what the design wants:<Div text={lines} lineAs="span" />.
The anti-pattern is raw interpolation, and it fails three ways at once:
<p className="…">{content.pretitle}</p> // arrays concatenate with no separator,
// inline markup shows as literal tags,
// authored links never resolve
<P text={content.pretitle} className="…" /> // correct on all three, string or arrayYou'll find both spellings in the templates — the raw form predates multi-line slots and survives where the content it renders happens to be a single plain line. The kit component costs nothing and covers everything, so it's the spelling to reach for.
When the design wants the lines treated differently — subtitle line one large, line two small, as a CV header does — reach for CSS on the line elements first (h2 > div children are ordinary selectors away: :first-child, :last-child). Only when lines need genuinely different markup, normalize and lay them out yourself:
const lines = [].concat(content.subtitle || [])Items are the repetition
items is where repeating content lands — cards, questions, entries, tiers — and each item carries the same shape as the section: its own title, pretitle, subtitle, paragraphs, icons, links, data, everything. A pricing tier arrives as { pretitle: "Most popular", title: "Pro", subtitle: "$29 / month", paragraphs: […] } with no configuration on your side.
Two habits keep item handling durable:
Handle 0, 1, and N uniformly. Authors control the count. A component that assumes three feature cards breaks on the site that has four; a grid that maps whatever arrives doesn't. When a count genuinely matters to the design (a two-column comparison), declare it in your content description — and then render exactly what you declared. A component that documents "up to three items" and receives five ignores the extra two, silently: that's the contract section below applied to counts. Unrendered is the normal state of over-provided content, and the declaration is what makes the silence legible — the author can read what the design uses. Treating extra content as a fault to surface is validation thinking imported from frameworks where input can be invalid; here no content is invalid.
Read item slots the same way as section slots. The same kit components, the same string-or-array handling, the same sparse-content tolerance. Symmetry here is what lets an author restructure — promote a card's content to the section, split a section into cards — without the component caring.
One level: items don't nest. Structure deeper than one list per section arrives through child sections, lists (content.lists is recursive), or tagged data blocks — if you're trying to reconstruct a hierarchy from items, the content wants one of those instead.
Slots are roles, not sizes
Heading levels never reach you, on purpose. Whether the author wrote # or ###, what arrives is which slot the line fills — and your component decides everything visual. A pretitle isn't small because the author made it small; it's small because you style pretitles small. This is the license that makes one section type work across documents written at different heading depths.
The corollaries:
- Render
pretitleindependently oftitle. Authors can label an untitled block (the#>label line), so a pretitle with an empty title is legal content, not a malformed state. - Don't infer importance from anything but the slot. There is no level field to branch on, deliberately.
- Order is yours. The parse doesn't tell you whether the icon came before the heading in the markdown, and your layout shouldn't care — authors write in meaning-order, you arrange in design-order. When source order is the design (an article body), that's the other view:
Two views: grouped and sequence
Everything above is the grouped view — content bucketed into named slots. content.sequence is the other lens: every element in document order, untouched. Both are always present; choosing is a design decision, not a capability gap.
- Grouped suits structured layouts: heroes, grids, tiers — anywhere the design owns the arrangement.
- Sequence suits prose: articles, documentation bodies — anywhere the author owns the order. Kit's
Proserenders it with typography, including#>label lines as<p data-role="pretitle">(style them via[data-role="pretitle"]).
Don't straddle: reconstructing document order from grouped fields (interleaving paragraphs and images by guesswork) re-implements sequence badly. If you're doing that, switch views.
Two notes for generic queries over sequence: heading elements include #> label lines (they carry attrs.role === "pretitle" and a meaningless level), so a filter like el.type === 'heading' picks up pretitles unless it excludes the role. And the headings field only ever fills from nested content (headings inside blockquotes or list items) — a section's own headline never lands there, and for a table of contents you want the useHeadings() hook, which works during prerender, not this field.
The content interface is yours to declare
Your meta.js carries a content: description alongside params:. uniweb docs collects every section type's description into the project's COMPONENTS.md — the catalog authors consult before writing. That makes the description your half of a two-way contract:
- Authors write to what you declare. Say your Stats section reads a title and items with a number-title and one-line label, and that's what you'll receive.
- You may ignore what you didn't ask for. Extra content an author provides is parsed and present, and skipping it is legitimate — a
compactparam that renders less of the same content thanfullis an established pattern, not a bug. - Authors may over-provide on purpose. The content is the superset; params select.
Being honest about the seam: the description is not validation. Nothing rejects content that ignores it — a section renders with whatever arrived. uniweb inspect is the shared instrument when the two sides disagree about what a document contains.
It is not only documentation either, and that changed recently. The visual editor reads your content: declaration twice: to show an author what this section expects before they pick it, and to fill a new section with starter content so it opens with something to edit rather than an empty box. The counts are what decide how much. So the third bullet above — authors may over-provide on purpose — is the rule the editor follows on their behalf: it generates for the upper bound of a range, because you may ignore what you did not ask for, and a shortfall would leave a hole that surplus does not.
⭐ What this buys you is concrete. items: { label: 'Feature cards [3-6]', hint: 'Each H3 becomes a card' } gets an author three to six real cards and your hint beside them; items: 'Cards' gets them a generic guess. The hint is worth writing for the same reason — it is shown, not just stored. You can see exactly what an author would get:
uniweb add section <Name> --starter⛔ There is no key for authoring sample content yourself, deliberately. It would drift against the declaration it is meant to match, and it could never be translated — your string is your own words, shown verbatim in every interface language. Deriving it from content: is what lets it be localized.
When content looks wrong
uniweb inspect <file> prints the parsed shape of any section — run it before debugging your component, because two authoring misfires produce content that looks like a component bug:
- The first entry is missing from
itemsand an extra subtitle line appeared. The author put their first entry one step under the headline with nothing between; it joined the headline as a subtitle line. Author-side fix: entries two steps down, or a lead paragraph, or a---. titleis empty and the headline text shows up initems[0]. The author wrote text above their title (usually a hand-made eyebrow); body-before-heading makes the section an untitled main with items. Author-side fix: the#>label line.
Recognizing these saves you from "fixing" a correct component against misparsed content — and from adding defensive code for shapes that a one-line content edit resolves.
The interface stops at the front desk
Everything in this guide describes one kind of component: the section type — the component an author names in type:. It has this particular interface because it's the bridge into the author's world; the parsed shape is what makes it addressable from markdown.
Nothing behind the bridge needs to speak it. The established pattern is the front desk: the section type reads content and params once, then delegates to ordinary React components with whatever props interface suits them — narrow, typed, yours. A Testimonials section that maps items into a plain <QuoteCard author quote role /> keeps the inner component reusable anywhere React runs, and keeps the content shape where it belongs. Passing content down through several layers is the smell that the translation didn't happen at the door.
So the mental model to leave with: one component per section type receives the author's world and translates it; everything past that point is the React you already write. The Front Desk pattern covers the delegation side in depth.
See Also
- Content Structure — the field-by-field reference, including how headings map to slots
- Creating Components — component basics,
meta.js, params - Component Patterns — the Front Desk, structured data, multi-source rendering
- Kit Reference — the text components,
Prose,useHeadings()