The Blog

We built a structured data helper for Payload blocks, here's how it works

Headless CMSTechnical SEO

Structured data tells search engines what your content actually is, not just that text exists on a page, but that this section is a FAQ, that block is a testimonial, those cards are pricing tiers. It's what makes your pages eligible for rich results, featured snippets, and the kind of search presence that drives clicks over competitors.

On a block-based CMS like PayloadCMS, pages are assembled from reusable content blocks. Any combination can appear on any page. We needed structured data that could keep up with that flexibility. generated automatically from the blocks themselves, not hardcoded per page.

Here's how we built it.

The problem

Why most CMS sites get structured data wrong

Structured data is how search engines understand what's actually on your page. Not just "there's text here", but that this section is a FAQ, that block is a testimonial, those cards are pricing tiers. Without it, Google is guessing. With it, your content becomes eligible for rich results, featured snippets, and the kind of search presence that drives clicks.

Most teams handle structured data one of two ways. Either they bolt JSON-LD onto a handful of important pages manually and hope nothing changes, or they ignore it entirely and rely on Google to figure it out.

Both approaches break the moment marketing rearranges the page. A FAQ section gets added to the services page. A testimonial carousel appears on the homepage. A pricing table moves from one landing page to another. The structured data doesn't follow, because it was hardcoded for a specific page layout, not generated from the content itself.

On a block-based CMS like PayloadCMS, where pages are assembled from reusable content blocks, this problem multiplies. Any combination of blocks can appear on any page. Manual structured data can't keep up.

We wanted something that scales with the CMS: every block type gets its own schema.org mapping, and the page assembles whatever's relevant automatically.

The approach

One generator function per block type

Each Payload block gets a matching generate* function that maps its fields onto a schema.org type. A PricingBlock becomes an array of Offer objects. A TestimonialsCarouselBlock becomes an array of Review objects. A TeamBlock becomes Person entries, falling back to an Organisation if no members are populated.

typescript
1export function generateTestimonial(block: TestimonialsCarouselBlock): WithContext<Review>[] {
2 const results: WithContext<Review>[] = []
3
4 for (const item of block.testimonials ?? []) {
5 if (typeof item === 'string') continue
6
7 const reviewBody = item.testimonial || ''
8 const authorName = item.authorName || ''
9 if (!reviewBody && !authorName) continue
10
11 results.push({
12 '@context': 'https://schema.org',
13 '@type': 'Review',
14 reviewBody: reviewBody || undefined,
15 author: authorName
16 ? { '@type': 'Person', name: authorName, jobTitle: item.authorPosition || undefined }
17 : undefined,
18 reviewRating: item.rating
19 ? { '@type': 'Rating', ratingValue: item.rating, bestRating: 5 }
20 : undefined,
21 itemReviewed: { '@type': 'Organization', name: 'Little Dash.' },
22 datePublished: item.createdAt || new Date().toISOString(),
23 dateModified: item.updatedAt || new Date().toISOString(),
24 })
25 }
26
27 return results
28}

Every generator follows the same contract: take the raw Payload block, return either a populated JSON-LD object, an array of them, or null / an empty array if there's nothing worth emitting. That last part matters, an empty Article with no headline and no description is worse than no Article at all. Defensive output is a core principle.

Rich text

Handling rich text without shipping HTML into schema fields

Payload's Lexical editor gives you structured rich text, not plain strings, and schema.org fields like description and reviewBody want plain text. We lean on convertLexicalToPlaintext from @payloadcms/richtext-lexical/plaintext everywhere a field could contain rich text:

typescript
1const description = block.richText ? convertLexicalToPlaintext({ data: block.richText }) : ''

For places where HTML sneaks in from elsewhere (CMS titles, mostly), there's a small sanitiser that strips scripts, styles, and tags before collapsing whitespace:

typescript
1function stripHtmlToPlainText(html: string): string {
2 return html
3 .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
4 .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
5 .replace(/<[^>]+>/g, ' ')
6 .replace(/\s+/g, ' ')
7 .trim()
8}

Nothing clever, just enough to guarantee nothing malformed ends up inside a <script type="application/ld+json"> tag.

Dates

Dates that don't lie

A subtle bug we wanted to avoid: emitting a dateModified that's earlier than datePublished, which happens easily if you're pulling updatedAt from a different source than createdAt. The fix is a small resolver that clamps dateModified to never precede datePublished:

typescript
1function resolveSchemaDates(args: {
2 publishedAt?: string | Date | null
3 createdAt?: string | Date | null
4 updatedAt?: string | Date | null
5}): { datePublished?: string; dateModified?: string } {
6 const published = toIsoDate(args.publishedAt) || toIsoDate(args.createdAt)
7 const updated = toIsoDate(args.updatedAt)
8 const modified =
9 published && updated
10 ? new Date(updated) >= new Date(published)
11 ? updated
12 : published
13 : updated || published
14
15 return {
16 ...(published ? { datePublished: published } : {}),
17 ...(modified ? { dateModified: modified } : {}),
18 }
19}

It also refuses to invent a "now" timestamp when there's genuinely no date data, better to omit the field than fabricate one. Search engines penalise inconsistent date signals, and most structured data validators will flag the mismatch.

Navigation

Breadcrumbs that respect your collection structure

Breadcrumb structured data helps search engines understand your site hierarchy, and earns those breadcrumb trails in search results. Posts, work items, and nested pages all build their BreadcrumbList differently. Posts get Home → Blog → Post title. Work items get Home → Work → Project title. Regular pages walk whatever breadcrumb trail is stored on the document itself:

typescript
1function generateBreadcrumbList(args: {
2 collection?: keyof typeof collectionPrefixMap
3 doc: Partial<Page> | Partial<Post> | Partial<Work>
4 url: string
5 serverUrl: string
6}): WithContext<BreadcrumbList> | null {
7 if (args.url === '/') return null
8 // ...builds crumbs based on collection type, skips "home" segments,
9 // returns null if fewer than 2 crumbs would result
10}

Returning null for the homepage, instead of a single "Home" crumb, avoids the useless single-item breadcrumb lists that trip up some rich-result validators.

Headless CMS

Structured data is one of the things that's easy to get wrong

If you're considering a move to a headless CMS like PayloadCMS, we build platforms where structured data, performance, and content architecture are handled from day one, not bolted on later.

Duplicates

Avoiding duplicate Article and FAQPage nodes

This was the hardest part to get right. Several block-level generators (banner, CTA, media-and-content, highlight text) all map to Article when they have enough content. But a blog post page also generates its own primary Article from the post metadata. Merge those together and you get duplicate Article headlines on the same page, which Google Rich Results flags outright.

The fix is a filter that strips block-level Article nodes before merging with the page-level one:

typescript
1function isArticleJsonLdNode(node: WithContext<Thing>): boolean {
2 const t = (node as { '@type'?: string | string[] })['@type']
3 if (t === 'Article') return true
4 if (Array.isArray(t) && t.includes('Article')) return true
5 return false
6}
7
8export function withoutBlockArticleStructuredData(
9 blockNodes: WithContext<Thing>[],
10): WithContext<Thing>[] {
11 return blockNodes.filter((node) => !isArticleJsonLdNode(node))
12}

Same logic applies to FAQPage, Google only accepts one per URL, so the FAQ group generator collapses multiple FAQ groups into a single FAQPage with a combined mainEntity array instead of emitting one FAQPage per group.

Assembly

Wiring it together

The last piece is a merge function that filters out null/undefined and hands back a flat array ready to serialise:

typescript
1export function mergeStructuredData(
2 ...entries: (WithContext<Thing> | null | undefined)[]
3): WithContext<Thing>[] {
4 return entries.filter(Boolean) as WithContext<Thing>[]
5}

At the page level, you call the relevant generators for whatever blocks are present, merge them, strip duplicate Article/FAQPage nodes, and drop the result into a <script type="application/ld+json"> tag. The page doesn't need to know which blocks are on it, the generators handle everything.

The result

What this means in practice

Every page on the Little Dash website gets valid, accurate structured data without anyone having to think about it. When marketing adds a FAQ section to the services page, the FAQPage schema appears automatically. When a testimonial carousel gets added to a landing page, Review markup follows. When a new page is assembled from any combination of blocks, the structured data reflects exactly what's on the page.

No manual JSON-LD per page. No risk of structured data falling out of sync with the content. No developer ticket every time a page layout changes.

For a headless CMS setup where the content team can assemble pages from any combination of blocks, this consistency is the whole point. The structured data scales with the content — not against it.

This approach also matters for web performance and search visibility. Valid structured data makes your content eligible for rich results, FAQ dropdowns, review stars, breadcrumb trails, pricing information — all of which increase click-through rates from search. Pages with rich results consistently outperform plain blue links.

Structured data that's hardcoded to specific pages breaks the moment the content changes. Structured data that's generated from the content blocks themselves never falls behind.

Little Dash

Not sure if your structured data is working?

We audit structured data for Australian businesses, checking for errors, missing schema, and opportunities you're not capturing. If your site runs on a CMS, there's a good chance it's leaving search visibility on the table.