This guide explains how to render schema.org JSON-LD on your Lynkow-powered site using the renderJsonLdGraph helper, how the server-side cascade merges nodes across four levels (site, category, page, content), and how to customize the emitted graph from the admin or via the MCP.
Prerequisites
Lynkow SDK installed and a client initialized (see Guide 01: Quick Start).
At least one category and one published content on your site, or a published page.
Basic familiarity with schema.org and JSON-LD. If you are new to the topic, the Google Structured Data documentation is a good primer.
// lib/lynkow.ts
import { createClient } from 'lynkow'
export const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
fetchOptions: {
next: { revalidate: 300 },
},
})1. Why JSON-LD, and what Lynkow does for you
Search engines and AI assistants (Google AI Overviews, ChatGPT, Perplexity) rely on JSON-LD blocks to understand the structure of a page: is this an article? a product listing? a local business? who is the publisher? Missing or malformed JSON-LD means you lose rich results in SERPs and risk being filtered from AI answers.
Lynkow ships three pieces so you almost never have to hand-author the JSON-LD:
An opinionated catalog of 27 typed presets (Article, Product, LocalBusiness, HowTo, FAQPage, etc.) your content editors pick from in the admin.
A server-side cascade that merges nodes declared at the site, category, page, and content levels into a single resolved
@graphwith stable@idvalues.The
renderJsonLdGraphSDK helper that wraps the resolved array in a single<script type="application/ld+json">tag, with a</script>injection guard built in.
What you do from the frontend is a single one-liner per page:
<div dangerouslySetInnerHTML={{ __html: renderJsonLdGraph(content.structuredData?.graph) }} />Everything else (picking types, filling fields, merging levels, escaping) happens in the admin and on the server.
2. The cascade model
Lynkow stores JSON-LD nodes at four levels. The server merges them at render time, so every public response carries an already-resolved @graph.
Site (seo_settings) <- Organization, WebSite, Publisher, global nodes
|
+-- Category <- LocalBusiness, Service (inherited by every content)
| |
| +-- Content (leaf) <- Article, Product, HowTo, ... (per-article override)
|
+-- Page (site_block) <- AboutPage, ContactPage, WebPage-specific nodesEach level stores two fields:
jsonLdGraph: the array of nodes declared at this level.jsonLdExclusions: a list of parent-level@idvalues this level opts out of.
Merge rules
For a given leaf (content or page), the server computes the effective graph as:
Take site nodes, minus the ones excluded by the category or the leaf.
Add category nodes, minus the ones excluded by the leaf.
Add leaf nodes.
Append auto-nodes (see section 6) that are neither excluded nor overridden by a leaf node sharing the same
@id.If the leaf declares a node with the same
@idas a site or category node, the leaf wins (downstream overrides upstream).
You never implement this on the client. The resolved graph lands in the public API response under structuredData.graph.
Where each level is configured
Level | Admin URL |
|---|---|
Site |
|
Category | Category edit drawer on |
Page | SEO tab of |
Content | "JSON-LD structured data" accordion in the content editor |
Every surface embeds the same editor, which shows three sections: inherited nodes from parents (with a per-node exclude toggle), nodes declared at the current level (add/edit/delete), and a final merged graph preview.
Stable @id by scope
Each cascade entry's rendered @id is computed from a scope that decides which URL prefix to use:
sitescope -><site-url>#<entry-id>. Default for entries declared at the site level (SeoSetting.jsonLdGraph) and at the category level. The entities they hold (Organization,LocalBusinessfor a section's pages,Publisher,WebSite) are logically site-wide; their@idstays stable across every page that includes them.pagescope -><page-url>#<entry-id>. Default for entries declared at page or content level. Per-article and per-page entities (Article,Product, customPerson) get an@idthat is unique to the URL that renders them. Auto-emitted nodes (auto:webpage,auto:article) always use this scope.
Override per entry via the optional scope: 'site' | 'page' field. Useful when you place a content-level Organization that should still be cross-page stable, or a site-level entry that should vary per page (rare).
Why this matters: cross-references between cascade entries (e.g. a Reviews preset's itemReviewedId pointing at the parent Organization, a custom Person.worksFor pointing at the same Organization) resolve through a per-page symbol table the cascade builds before any preset runs. As long as you reference a sibling by its short id, the cascade rewrites the reference to the actual rendered @id, regardless of which scope each side uses. Two acceptable forms for cross-references:
Short id:
"itemReviewedId": "site-org"-> resolves to the actual<site-url>#site-orgof the parent.Absolute URL with the parent's actual
@id:"itemReviewedId": "https://example.com/#site-org"-> matches as-is.
A third form survives for backward compatibility: an absolute URL whose suffix matches a known short id (e.g. https://example.com/avis-clients#site-org when the cascade has an entry with id: 'site-org'). The cascade auto-rewrites it to the sibling's actual @id and emits a one-shot deprecation log so you can update your data.
3. Rendering on an article (Next.js App Router)
The simplest case: one article page serving one merged JSON-LD graph.
// app/blog/[slug]/page.tsx
import { renderJsonLdGraph } from 'lynkow'
import { lynkow } from '@/lib/lynkow'
interface Props {
params: Promise<{ slug: string }>
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const content = await lynkow.contents.getBySlug(slug)
return (
<>
<div
dangerouslySetInnerHTML={{
__html: renderJsonLdGraph(content.structuredData?.graph),
}}
/>
<article>
<h1>{content.title}</h1>
<div dangerouslySetInnerHTML={{ __html: content.body }} />
</article>
</>
)
}content.structuredData?.graph is typed as object[] | undefined. It contains fully-formed schema.org objects with @context, @id, and @type.
The helper:
returns
''whengraphisundefined,null, or an empty array, so you can safely spread it in server components without conditionals;strips the per-node
@contextand wraps every node under a single top-level@contextplus a@grapharray, keeping the emitted tag compact;escapes any
</script>sequences embedded in string values, so a content editor pasting arbitrary text into a custom field cannot break out of the tag.
Placement in the document
Browsers and crawlers accept the <script type="application/ld+json"> tag anywhere. The common choices:
Just before the
<article>body (shown above): keeps the JSON-LD colocated with the content it describes.Inside Next.js
headviaMetadata.otheror a<Script>component: better when you have multiple JSON-LD tags and want to centralize them.
Google's crawler does not care about position, so pick the one that fits your layout best.
4. Rendering on a page
Pages (the "Site Blocks" of type page) expose the same resolved graph but under a different shape: Page.structuredData is an inline object with a single graph property, not the full StructuredData type used by articles.
// app/[slug]/page.tsx
import { renderJsonLdGraph } from 'lynkow'
import { lynkow } from '@/lib/lynkow'
interface Props {
params: Promise<{ slug: string }>
}
export default async function StaticPage({ params }: Props) {
const { slug } = await params
const page = await lynkow.pages.getBySlug(slug)
return (
<>
<div
dangerouslySetInnerHTML={{
__html: renderJsonLdGraph(page.structuredData?.graph),
}}
/>
<h1>{page.name}</h1>
{/* render page data */}
</>
)
}For a page, the resolved graph always includes an auto-generated WebPage node and a BreadcrumbList derived from the URL path, plus any site-level nodes the page did not exclude and any custom nodes declared in the page's SEO tab.
5. The dedicated page JSON-LD endpoint
If you need the raw JSON-LD array without the full page payload (for example, to serve it under a separate URL for external crawlers, or to regenerate it on a static export without re-rendering the page), use the dedicated endpoint:
GET /public/:siteId/pages/:slug/json-ldResponse shape:
{
"data": [
{ "@context": "https://schema.org", "@id": "https://example.com/about#auto:webpage", "@type": "WebPage", "name": "About" },
{ "@context": "https://schema.org", "@id": "https://example.com/about#auto:breadcrumb", "@type": "BreadcrumbList", "itemListElement": [ ] }
]
}Use it from a Next.js route handler:
// app/pages/[slug]/json-ld/route.ts
import { renderJsonLdGraph } from 'lynkow'
export async function GET(
_req: Request,
ctx: { params: Promise<{ slug: string }> }
) {
const { slug } = await ctx.params
const res = await fetch(
`${process.env.LYNKOW_API_URL}/public/${process.env.NEXT_PUBLIC_LYNKOW_SITE_ID}/pages/${slug}/json-ld`
)
if (!res.ok) return new Response('Not Found', { status: 404 })
const { data } = (await res.json()) as { data: object[] }
return new Response(renderJsonLdGraph(data), {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}There is no content-level equivalent endpoint: for articles, the graph ships inline on GET /public/:siteId/blog/:slug as content.structuredData.graph, which avoids a second round-trip per page.
6. Auto-nodes explained
Lynkow injects four implicit nodes at render time, identified by the reserved auto: prefix. You never declare them, but you can suppress or override them.
| Emitted on | What it represents |
|---|---|---|
| Content |
|
| Content with FAQ blocks |
|
| Content and page |
|
| Page |
|
Organization is not auto-injected
Unlike the four auto-nodes above, Organization is never emitted implicitly. Declare it once at the site level via the admin UI (/dashboard/settings/seo/json-ld) or the update_seo_settings MCP tool, and the cascade forwards it into every content and page graph.
{
"tool": "update_seo_settings",
"input": {
"siteId": "<your-site-uuid>",
"jsonLdGraph": [
{
"id": "site-org",
"type": "Organization",
"source": "preset",
"data": {
"name": "Acme Agency",
"url": "https://acme.example",
"logo": "https://acme.example/logo.png"
}
}
]
}
}Suppressing an auto-node
To turn off an auto-node on a specific leaf, add its @id to jsonLdExclusions on that level.
Example: on a content page where you want to emit your own Product node as the primary entity, you can exclude auto:article so search engines only see the Product:
{
"jsonLdGraph": [
{
"type": "Product",
"source": "preset",
"data": { "name": "House wine", "offers": { "price": "9.90", "priceCurrency": "EUR" } }
}
],
"jsonLdExclusions": ["auto:article"]
}Overriding by @id
Any leaf node declared with @id: "auto:..." would be rejected by the validator (the auto: prefix is reserved). If you need to replace an auto-node entirely, either:
exclude the auto-node via
jsonLdExclusionsand add a node with your own id, ordeclare a node with the same user-provided id at a higher level (site or category), then override it at a lower level; the cascade will pick the leaf version automatically.
7. Typed presets catalog
28 presets ship with the platform, grouped by where they are most useful. Content editors pick them from a dropdown in the admin; the MCP accepts them as source: 'preset'.
Site-level: WebSite, Organization, Publisher, BreadcrumbList.
Page and category-level: WebPage, AboutPage, ContactPage, CollectionPage, LocalBusiness, Service.
Content-level: Article, BlogPosting, NewsArticle, HowTo, Product, Recipe, Event, Course, VideoObject, CaseStudy, FAQPage, SoftwareApplication, JobPosting, Review, AggregateRating, ProfilePage, QAPage.
Multi-node (auto-emit one node per matching DB row): Reviews (see section 8).
The admin form displays the preset-specific fields with labels and validation. The sync engine handles translations and falls back to context values (page URL, locale, default author) when a field is left empty.
If the schema.org type you need is not in the catalog, use source: 'custom' with a raw data object; see section 9.
Adding extra schema.org fields to a preset
Each typed preset enumerates the most common schema.org fields in its admin form, but the preset is not a closed schema. Any extra valid schema.org property posted in a preset's data (via API V1, MCP, or the admin's extra-fields slot) is passed through verbatim into the rendered JSON-LD on top of the preset's typed output.
Concrete example: a LocalBusiness preset can be enriched with openingHoursSpecification, hasOfferCatalog, currenciesAccepted, slogan, legalName, foundingDate, etc., even though the admin form only exposes name, address, geo, openingHours, areaServed, priceRange, and a few others. The server merges everything into a single LocalBusiness node, with the typed transformations (address becomes a PostalAddress sub-node, aggregateRatingAuto: true triggers the auto-compute) winning over user-supplied raw values for the same key.
{
"type": "LocalBusiness",
"source": "preset",
"data": {
"name": "Example Agency",
"telephone": "+33 1 23 45 67 89",
"aggregateRatingAuto": true,
"openingHoursSpecification": [
{ "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday"], "opens": "09:00", "closes": "18:00" }
],
"hasOfferCatalog": {
"@type": "OfferCatalog",
"name": "Services",
"itemListElement": [
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Consulting" } }
]
}
}
}Renders as a single LocalBusiness with name, telephone, address, geo, the auto-computed aggregateRating, plus openingHoursSpecification and hasOfferCatalog passed through unchanged.
The Reviews preset is the only exception: it expands into one schema.org Review node per approved review, with a fixed shape per node. Extras on data (other than the documented filters: itemReviewedId, limit, minRating, localeFilter) are not currently propagated to each emitted Review. If you need to enrich individual Review nodes, declare them as source: 'custom'.
8. Auto-emit reviews via the Reviews preset
When the Reviews module is active, the cascade can emit one schema.org Review node per approved review automatically. Each node carries its own stable @id and references the parent entity (a site-level LocalBusiness or Organization) via itemReviewed. This is the recommended path on a /reviews or /avis-clients page; it replaces hand-rolling a second <script type="application/ld+json"> tag in your frontend (which risks @id collisions, see section 12).
Add the Reviews preset to the level where you want them rendered, typically the content of your reviews page:
{
"tool": "update_content",
"input": {
"siteId": "<your-site-uuid>",
"id": "<reviews-page-content-uuid>",
"jsonLdGraph": [
{
"id": "reviews-section",
"type": "Reviews",
"source": "preset",
"data": {
"itemReviewedId": "site-organization",
"limit": 20,
"minRating": 1,
"localeFilter": false
}
}
]
}
}Fields
Field | Required | Default | Notes |
|---|---|---|---|
| yes | - | Reference to the parent schema.org entity. See "How |
| no |
| Max number of Review nodes to emit. Capped at |
| no |
| Only include reviews with at least this rating (1-5). |
| no |
| When |
How itemReviewedId is resolved
Each emitted Review carries itemReviewed: { "@id": "<resolved>" }. The cascade builds a per-render symbol table mapping every entry's short id to its actual rendered @id, then resolves itemReviewedId through three accepted input forms:
Short id (no scheme), e.g.
"itemReviewedId": "site-org". Looked up in the symbol table; resolves to the parent entry's actual@idregardless of its scope. This is the recommended form because it auto-tracks any future scope or URL change. Works for parents declared at any cascade level.Absolute URL whose suffix is a known short id, e.g.
"https://example.com/avis-clients#site-org"when an entry hasid: 'site-org'. The cascade auto-rewrites the reference to the sibling's actual@id(typicallyhttps://example.com/#site-orgfor a site-scoped Organization) and emits a one-shot deprecation log so you can update your data.Absolute URL with no matching short id, e.g.
"https://other-site.example.com/x#y". Used verbatim. Treat as an off-graph reference to an entity Lynkow doesn't control. The cascade still emits awarnOnOrphanReferenceslog if no node in the resolved@graphmatches.
If a Review's itemReviewed.@id matches no node in the resolved @graph, the API logs a server-side warning. The Review nodes are still emitted, but Google will treat them as orphans and may not link them to your parent for rich results. Inspect the rendered HTML or your server logs after a deploy to catch this.
End-to-end example
Here's a complete migration on a /avis-clients page implemented as a site_block. The parent LocalBusiness is declared at the site level with a custom @id, so the Reviews preset references it via the absolute URL form.
Site-level setup (one-time):
{
"tool": "update_seo_settings",
"input": {
"siteId": "<site-uuid>",
"jsonLdGraph": [
{
"id": "site-localbusiness",
"type": "LocalBusiness",
"source": "custom",
"data": {
"@type": "LocalBusiness",
"@id": "https://www.example.com/#organization",
"name": "Example Agency",
"telephone": "+33 1 23 45 67 89",
"address": { "@type": "PostalAddress", "addressCountry": "FR" }
}
}
]
}
}Page-level setup on the avis-clients site_block:
{
"tool": "update_site_block_data",
"input": {
"siteId": "<site-uuid>",
"slug": "avis-clients",
"data": {},
"jsonLdGraph": [
{
"id": "reviews-section",
"type": "Reviews",
"source": "preset",
"data": {
"itemReviewedId": "https://www.example.com/#organization",
"limit": 50,
"minRating": 1,
"localeFilter": false
}
}
]
}
}Resulting @graph on GET /public/<siteId>/pages/avis-clients/json-ld (truncated):
[
{
"@context": "https://schema.org",
"@id": "https://www.example.com/#organization",
"@type": "LocalBusiness",
"name": "Example Agency",
// ...
},
{
"@context": "https://schema.org",
"@id": "https://www.example.com/avis-clients#auto:webpage",
"@type": "WebPage",
"name": "Avis clients"
},
{
"@context": "https://schema.org",
"@id": "https://www.example.com/avis-clients#auto:breadcrumb",
"@type": "BreadcrumbList",
"itemListElement": [ /* ... */ ]
},
{
"@context": "https://schema.org",
"@id": "https://www.example.com/avis-clients#review-abc123",
"@type": "Review",
"author": { "@type": "Person", "name": "Alice" },
"datePublished": "2026-04-01",
"reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5, "worstRating": 1 },
"reviewBody": "Excellent service.",
"itemReviewed": { "@id": "https://www.example.com/#organization" }
// ... 49 more Review nodes
}
]itemReviewed.@id matches the LocalBusiness @id exactly, so Google ties the Reviews to the correct entity and the rich results test passes.
What gets emitted
For each approved review (most recent first), the cascade emits:
{
"@context": "https://schema.org",
"@id": "https://example.com/avis-clients#review-<reviewId>",
"@type": "Review",
"author": { "@type": "Person", "name": "Alice" },
"datePublished": "2026-04-01",
"reviewRating": {
"@type": "Rating",
"ratingValue": 5,
"bestRating": 5,
"worstRating": 1
},
"reviewBody": "Excellent service.",
"name": "Top notch", // only when the review has a title
"itemReviewed": { "@id": "https://example.com/avis-clients#site-organization" },
"inLanguage": "fr"
}Pending and rejected reviews are never included. Email, IP and other private fields are never exposed.
Auto-compute aggregateRating on the parent entity
Individual Review nodes alone are not what triggers the star rating + "(N reviews)" snippet in Google search results. Google reads aggregateRating on a LocalBusiness, Organization or Product for that. The LocalBusiness, Organization and Product presets accept a boolean opt-in field aggregateRatingAuto: when true, the cascade computes ratingValue (avg) and ratingCount from approved reviews at render time and injects an AggregateRating node into the parent entity automatically.
{
"id": "site-localbusiness",
"type": "LocalBusiness",
"source": "preset",
"data": {
"name": "Example Agency",
"telephone": "+33 1 23 45 67 89",
"aggregateRatingAuto": true
}
}Renders to:
{
"@context": "https://schema.org",
"@id": "https://example.com/#site-localbusiness",
"@type": "LocalBusiness",
"name": "Example Agency",
"telephone": "+33 1 23 45 67 89",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.7,
"ratingCount": 105,
"bestRating": 5,
"worstRating": 1
}
}Notes:
Only reviews with
status: 'approved'are counted. Pending and rejected reviews are excluded.ratingValueis rounded to one decimal place.When the site has zero approved reviews,
aggregateRatingis omitted entirely (no misleading "0/5").An explicit
data.aggregateRating: {...}object always wins overaggregateRatingAuto: true. Useful for freezing a marketing number temporarily without disabling the flag.A new approved review appears in
aggregateRatingon the next backend render (the AVG + COUNT runs at request time, not cached). Any further delay you observe comes from your front-end's revalidation strategy (Next.js ISR, webhook-drivenrevalidatePath, etc.) - not from Lynkow.
Excluding the Reviews entry per page
The Reviews entry behaves like any other cascade node when it comes to exclusion: if you declared Reviews at the site level and want to suppress it on a specific page, add the entry's id (e.g. "site-reviews") to that page's jsonLdExclusions. All emitted Review nodes disappear together.
9. Custom JSON-LD escape hatch
When a preset does not fit your use case, declare a node with source: 'custom' and a complete schema.org object as data. The server validates basic structure (non-empty @type, no prototype-pollution keys) and emits the data verbatim, adding only the @id and @context headers.
Example (podcast episode, no preset exists):
{
"type": "PodcastEpisode",
"source": "custom",
"data": {
"@type": "PodcastEpisode",
"name": "Episode 42: Scaling Next.js on Vercel",
"episodeNumber": 42,
"datePublished": "2026-03-01",
"duration": "PT45M",
"associatedMedia": {
"@type": "MediaObject",
"contentUrl": "https://cdn.example.com/episodes/42.mp3"
}
}
}Restrictions
@idmust not start withauto:. Use any other stable string, or omit it so the server generatesown-<8hex>.datamust be a plain object. Keys named__proto__,constructor, orprototypeare rejected.The
jsonLdGrapharray is capped at 50 nodes per level, same forjsonLdExclusions.
10. Migrating from the legacy structuredData.article.jsonLd API
Before the cascade (Lynkow SDK versions earlier than the renderJsonLdGraph release), articles exposed two hand-built fields on the response:
// Legacy API - still returned for back-compat
content.structuredData?.article?.jsonLd // single Article object
content.structuredData?.faq?.jsonLd // FAQPage or null when no FAQThe new cascade replaces both with a single resolved array:
content.structuredData?.graph // object[] | undefinedBefore and after
Old pattern (do not use in new code, but still works):
{content.structuredData?.article?.jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(content.structuredData.article.jsonLd),
}}
/>
)}
{content.structuredData?.faq?.jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(content.structuredData.faq.jsonLd),
}}
/>
)}New pattern:
<div
dangerouslySetInnerHTML={{
__html: renderJsonLdGraph(content.structuredData?.graph),
}}
/>The new pattern is strictly better: one tag, proper </script> escaping, site + category + content cascade applied, and every configured node (including custom LocalBusiness, Product, etc.) ends up in the output.
When the graph field is missing
Some old API versions do not populate graph. For production robustness, fall back to the legacy fields:
const hasGraph = content.structuredData?.graph && content.structuredData.graph.length > 0
return (
<>
{hasGraph ? (
<div dangerouslySetInnerHTML={{ __html: renderJsonLdGraph(content.structuredData?.graph) }} />
) : (
<>
{content.structuredData?.article?.jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(content.structuredData.article.jsonLd),
}}
/>
)}
</>
)}
</>
)In practice this fallback is only needed during a staged rollout; once your Lynkow backend is up to date you can remove the legacy branch.
11. TypeScript reference
import type {
JsonLdNode,
JsonLdNodeSource,
JsonLdGraphConfig,
} from 'lynkow'
// Source of a node: typed preset with server-side field mapping,
// or raw custom JSON-LD passed through verbatim.
type JsonLdNodeSource = 'preset' | 'custom'
// One node as stored in a cascade level. The admin + MCP write this shape;
// the public API materializes it into a fully resolved schema.org object.
interface JsonLdNode {
id: string // never starts with 'auto:'
type: string // schema.org @type
data: Record<string, unknown> // preset fields or full schema.org payload
source: JsonLdNodeSource
}
// Per-level cascade config (wire shape persisted in the DB).
interface JsonLdGraphConfig {
graph: JsonLdNode[]
exclusions: string[] // parent @ids to opt out of
}The helper signature:
function renderJsonLdGraph(nodes: object[] | null | undefined): stringReturns the <script type="application/ld+json">...</script> tag, or an empty string when there is nothing to render.
12. Gotchas
FAQPage visibility requirement
Google treats invisible FAQ as spam. If you emit a FAQPage node via the content-level jsonLdGraph (for structured content without a body), the question and answer text MUST also appear in the visible page markup. A mismatch can trigger a manual action on your domain.
For articles with a Markdown body, prefer the :::faq directive. The Lynkow admin extracts Q/A pairs from that block, renders them visibly, and emits the matching FAQPage JSON-LD from the same source, so the two cannot drift apart.
Cache invalidation after admin edits
The SDK caches article and page responses (SWR with 5-minute TTL by default). When a content editor changes the cascade on a published page, your Next.js build will not see the new graph until the cache expires. Two options:
For on-demand updates (webhook-driven), call
lynkow.contents.clearCache()orlynkow.pages.clearCache()in your Lynkow webhook handler.For regular rebuilds, let the 5-minute TTL handle it.
Per-locale cascade
Each content, category, and site block row has its own jsonLdGraph. Translators maintain per-locale graphs independently. The site-level graph in seo_settings is a single row per site (not per locale), so Organization and WebSite declarations apply to every locale of the same site.
Custom fields on legacy page configs
Pages created before the cascade migration carried a jsonLdConfig object with customFields. The migration preserves those custom fields by mapping them into a legacy:webpage node. If you see legacy:webpage in a production graph, it means the page predates the migration or still relies on the legacy column; this is functionally equivalent to the auto-generated WebPage.
@id collisions when adding client-side nodes
If you add custom nodes from the frontend (see section 13), they share the same JSON-LD graph as the server's resolved nodes from the crawler's perspective: Google merges any two entities that share an @id into a single one, combining their properties. A common failure mode is to declare a custom Review/AggregateRating in your Next.js page with a generic id like "#organization", which collides with the server's site-level LocalBusiness. Google then sees two aggregateRating on a single entity and downgrades the rich result.
Two rules of thumb:
Prefix every custom
@idwith the page URL."https://example.com/reviews#review-42"is unique site-wide;"#review-42"is not.Reference server entities by
@id, never re-declare them. If you want a Review to point to a LocalBusiness already in the cascade, write"itemReviewed": { "@id": "<server-id>" }. Don't paste the LocalBusiness object into your custom node.
For the specific case of customer reviews, prefer the Reviews preset (section 8) which avoids this whole class of bug.
Migration: cross-page references after the scope split
Before the scope split, every cascade entry rendered with <page-url>#<id>. References that hardcoded that prefix on a now-site-scoped entity (e.g. a custom Person.worksFor: { "@id": "https://example.com/about#site-org" }) need to switch to either the short id or the new site-scoped URL:
Recommended: short id (
"@id": "site-org") - the cascade auto-resolves through the symbol table, no hardcoded URL to maintain.Acceptable: site-scoped absolute URL (
"@id": "https://example.com/#site-org") - what the cascade actually emits today for site-level entries.
Well-formed legacy references (a known short id at the end of an absolute URL) auto-rewrite to the new site-scoped target with a one-shot deprecation log on the API server. Update your data to silence the warning.
13. Adding client-side nodes safely
Sometimes you have data on your Next.js page that the server doesn't know about (third-party reviews, user-generated content fetched from another API, ad-hoc Event nodes for time-bound campaigns) and you want to merge them into the JSON-LD graph rendered with the server's resolved cascade. The SDK ships a mergeIntoGraph helper for that:
import { renderJsonLdGraph, mergeIntoGraph } from 'lynkow'
import { lynkow } from '@/lib/lynkow'
export default async function ReviewsPage() {
const page = await lynkow.pages.getBySlug('reviews')
const externalReviews = await fetchTrustpilotReviews()
const customNodes = externalReviews.map((r) => ({
'@context': 'https://schema.org',
'@id': `https://example.com/reviews#trustpilot-${r.id}`, // unique, prefixed with page URL
'@type': 'Review',
'author': { '@type': 'Person', 'name': r.author },
'reviewBody': r.body,
'reviewRating': { '@type': 'Rating', 'ratingValue': r.rating, 'bestRating': 5 },
'itemReviewed': { '@id': page.structuredData?.graph?.find((n) => n['@type'] === 'LocalBusiness')?.['@id'] },
}))
const merged = mergeIntoGraph(page.structuredData?.graph, customNodes)
return (
<>
<div dangerouslySetInnerHTML={{ __html: renderJsonLdGraph(merged) }} />
{/* page content */}
</>
)
}mergeIntoGraph does three things:
Concatenates the server graph and your custom nodes (server first).
Tolerates
null/undefinedon either argument so it's safe to call withpage.structuredData?.graphregardless of whether the server populated it.Detects
@idcollisions between the two arrays and emits aconsole.warn(development surface for the gotcha described in section 12). It does NOT renumber or deduplicate: the warning is a guardrail, the unique-@iddiscipline stays your responsibility.
The result is a plain object[] ready for renderJsonLdGraph. No further escaping or reshaping is needed.
Next Steps
Guide 17: Visual Editor - edit page content in-place with the Lynkow visual editor.
For an internal view of how the cascade resolves nodes (for contributors to the Lynkow platform), see the feature spec at docs/features/json-ld-cascade.md.