Skip to main content
日本語

Running structured data that does not break

Tomohiro Iida · Published June 30, 2026 · Updated June 30, 2026

Running a media site on Next.js (App Router) and Strapi v5 means emitting a large volume of JSON-LD per page: Article, FAQPage, BreadcrumbList, ItemList, and more. This article covers how we design that so it does not break, and how we built CI checks and audits around it, based on how netsujo.jp runs in production.

Why structured data breaks

Running a media site on Next.js (App Router) with Strapi v5 means outputting a large amount of Article, FAQPage, BreadcrumbList, and ItemList JSON-LD per page. These are not "write once and done" — the CMS data structure and the build method interact, and things break without anyone noticing.

What makes JSON-LD tricky is that the build still succeeds even when it is broken. JSON.stringify will happily stringify an invalid value, and npm run build does not check schema validity at all. As a result, problems such as required properties turning into undefined, dates that are not ISO 8601, @id references that do not resolve, and closing-tag-like text leaking in from article bodies can all reach production with no check catching them.

None of these affect what's visible on screen, so a human eye cannot catch them. That is exactly why you need to build in both a way of writing that does not break, and a mechanism that fails loudly when it does.

Embedding JSON-LD safely

On netsujo.jp, every JSON-LD component passes through a single shared serializer before being handed to dangerouslySetInnerHTML. It is a very short function whose only job is to escape any sequence containing a closing-tag-like "</" so that it cannot prematurely close the surrounding script tag.

If an article body (an excerpt or description) contains HTML tags or a code snippet, the DOM breaks without that escaping. Routing every component through this one function means we do not have to re-solve that problem each time we add a new schema.

What a reviewer should look for is any spot that passes a raw JSON.stringify output directly into dangerouslySetInnerHTML without going through the shared serializer.

Emitting Article JSON-LD under SSG

Article pages are built as static pages by default, using generateStaticParams to pull article slugs from Strapi and statically generate them at build time (fetching up to 100 at a time, paginating for anything beyond that).

If Strapi is unreachable at build time, generateStaticParams returns an empty array so the deploy still goes through, with individual pages then resolving at request time. Data fetching runs through a shared fetchStrapi helper’s next: { revalidate } option, and the article query uses revalidate: 3600 (one hour) to drive ISR. Pages do not set export const revalidate directly — caching is controlled at the fetch level instead (this assumes the conventional caching model; when Next.js 16’s Cache Components are enabled, revalidate behaves differently and needs to be checked separately).

For the Article JSON-LD output, the description strips HTML tags out of the excerpt field to produce plain text, and datePublished prioritizes a displayDate field for reasons covered below. When no author is set, the component falls back to emitting the representative’s Person node by default, so the on-page author card and the JSON-LD author agree — a mismatch between structured data and what is actually displayed can also be an issue under Google’s structured data guidelines.

Designing Breadcrumb and ItemList

Breadcrumbs appear on both article pages (three levels) and listing pages (two levels). The BreadcrumbJsonLd component is kept thin — it only maps a given array into ListItem entries with a position — while the logic that expresses the hierarchy lives in each page. That makes it possible to check, in one place, whether an article page’s own URL matches the last item in its breadcrumb.

Listing pages use ItemListJsonLd, generating both the displayed array and the JSON-LD array from the same sorted displayPosts array. If the order shown on screen and the ItemList position values drift apart it undermines the page’s trustworthiness, so both outputs are built from the same post-sort data.

We can help with JSON-LD implementation on Next.js App Router and Strapi, SSG/ISR design, and turning schema validation into a CI step, including a review of whether an existing site’s structured data is already broken.

Talk to us about development

The publishedAt mapping trap

This is the trap we most want readers to take away from this article. Strapi v5's publishedAt is the date and time an entry was published inside the CMS, and the system manages it — it does not necessarily match an article's real original publish date. Because netsujo.jp migrated articles that had previously been published on note into Strapi, using publishedAt as-is would make it look as though every article had been published on the day of the migration work.

As a countermeasure, /tech-blog keeps a slug-level map of the correct publish date and resolves it in priority order: an explicit displayDate field first, then a hardcoded map of original note publish dates, then Strapi’s publishedAt, with null as the final fallback.

Listings are then sorted in descending order using this resolved date. The Article JSON-LD’s datePublished follows the same idea, evaluating post.displayDate || post.publishedAt in that order.

The key point is demoting publishedAt to the last fallback. Reversing that order makes the published date in structured data disagree with the order of the listing, and breaks the freshness signal. The ideal fix is to add a proper displayDate field on the Strapi side and phase out the hardcoded map over time.

Why we keep FAQPage even after it stopped mattering for rich results

Google's FAQ rich result stopped appearing in Google Search results as of May 7, 2026. Even so, netsujo.jp keeps the FaqPageJsonLd component. Its role as a trigger for a SERP rich-result display is gone, but we keep it as structured information within the site, and on the chance that it is referenced by AI search or voice search (that latter benefit is not something Google guarantees).

Keeping it comes with a change in operating rules: an answer must be self-contained in its opening sentence, include concrete figures, conditions, or legal references, and cover only questions people actually search for. Empty yes/no answers and keyword-stuffed synthetic questions are removed.

Protecting structured data with CI and audits

This is the "fail loudly when it breaks" side of the system. On netsujo.jp we protect it with two layers.

Layer one: an audit script that checks production after the fact

npm run seo:audit:netsujo walks the sitemap, fetches the HTML of each page, and parses every script[type="application/ld+json"] block. It recursively expands any @graph to collect @type values and produces an inventory of which pages carry which schema types. This is not a build gate — it is positioned as a periodic, after-the-fact check against production.

Layer two: schema validation before merge

On any PR that adds or changes a JSON-LD component, we check each block against the schema.org spec and Google’s Rich Results requirements: required properties, date formats, whether @id references resolve, and whether a deprecated type is being used. @id graph consistency in particular cannot be caught by the build, so it is worth running as a dedicated check.

Separately, we also run a fact-consistency check (check:facts) in CI that does not depend on @type at all, statically scanning for prohibited claims. Structural validity and factual correctness are guarded by two different checks.

Frequently asked questions

Does ISR still work if a page does not export const revalidate?
Yes. Article pages have no page-level revalidate setting; the fetchStrapi data-fetching layer passes next: { revalidate } instead. The article query uses revalidate: 3600, and that value drives the ISR interval.
Why can't we just use Strapi's publishedAt as the display date?
Strapi v5's publishedAt is the date and time an entry was published in the CMS, managed by the system — it is a different thing from an article's real publish date. Without overriding it with displayDate or an explicit mapping, the publish date in structured data and the order shown in listings will not line up.
Is the FAQPage schema still worth keeping?
Not for rich results — that purpose is gone. Keeping it in the hope of being referenced by AI search or voice search is a valid choice, but that benefit is not guaranteed. If you keep it, limit it to high-quality Q&As whose answers are self-contained from the first sentence.
What is the benefit of turning JSON-LD into components?
It lets you centralize escaping, how @id values are assigned, and publisher/author defaults in one place, instead of re-implementing them on every page. It also means you don't have to repeat the same considerations each time you add a new schema.
If the build passes, is it safe to assume the JSON-LD is correct?
No. Neither JSON.stringify nor the Next.js build validates schema correctness. Set up schema validation before merge and a periodic audit as separate, dedicated checks.

We can help with JSON-LD implementation on Next.js App Router and Strapi, SSG/ISR design, and turning schema validation into a CI step, even before requirements are fully settled.

Build structured data that does not break