From 14539d4b1d263ab6337166d073cd235e36dc39b1 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Fri, 11 Sep 2026 14:42:45 +0200 Subject: [PATCH 1/2] Demo: Add Product structured data to the product detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a `Product` JSON-LD object, with a nested `Offer`, on the `/product/[slug]` route so search and generative engines can identify a product together with its price and availability. Products had no structured data before. Rendered by `ProductJsonLd`, a component that carries its own GraphQL fragment and renders itself — the same shape as the existing `OrganizationJsonLd` and `NewsArticleJsonLd`. Image and site URLs are resolved to absolute form, as crawlers require. The currency is a fixed `EUR` for now, since the site config carries none. Co-Authored-By: Claude Opus 4.8 --- .../[language]/product/[slug]/fragment.ts | 6 +- .../[language]/product/[slug]/page.tsx | 11 ++- demo/site/src/products/ProductJsonLd.tsx | 76 +++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 demo/site/src/products/ProductJsonLd.tsx diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/fragment.ts b/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/fragment.ts index 0968e041392..962e589ca38 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/fragment.ts +++ b/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/fragment.ts @@ -1,9 +1,9 @@ import { gql } from "@dextinity/site-nextjs"; +import { productJsonLdFragment } from "@src/products/ProductJsonLd"; export const fragment = gql` fragment ProductDetailPage on Product { - title - image - description + ...ProductJsonLd } + ${productJsonLdFragment} `; diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/page.tsx index cc8fcfe8fdb..d9cf089b02b 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/product/[slug]/page.tsx @@ -2,6 +2,7 @@ export const dynamic = "error"; import { gql } from "@dextinity/site-nextjs"; import type { VisibilityParam } from "@src/middleware/domainRewrite"; +import { ProductJsonLd } from "@src/products/ProductJsonLd"; import { createGraphQLFetch } from "@src/util/graphQLClient"; import { setVisibilityParam } from "@src/util/ServerContext"; import { notFound } from "next/navigation"; @@ -11,8 +12,9 @@ import { fragment } from "./fragment"; import type { GQLProductDetailPageQuery, GQLProductDetailPageQueryVariables } from "./page.generated"; export default async function ProductDetailPage({ params }: PageProps<"/[visibility]/[domain]/[language]/product/[slug]">) { - const { slug, visibility } = await params; + const { domain, language, slug, visibility } = await params; setVisibilityParam(visibility as VisibilityParam); + const scope = { domain, language }; const graphqlFetch = createGraphQLFetch(); const data = await graphqlFetch( @@ -32,5 +34,10 @@ export default async function ProductDetailPage({ params }: PageProps<"/[visibil notFound(); } - return ; + return ( + <> + + + + ); } diff --git a/demo/site/src/products/ProductJsonLd.tsx b/demo/site/src/products/ProductJsonLd.tsx new file mode 100644 index 00000000000..13ef49e13d7 --- /dev/null +++ b/demo/site/src/products/ProductJsonLd.tsx @@ -0,0 +1,76 @@ +import { generateImageUrl, gql, JsonLd } from "@dextinity/site-nextjs"; +import type { DamImageBlockData } from "@src/blocks.generated"; +import type { ContentScope } from "@src/site-configs"; +import { getSiteConfigForDomain } from "@src/util/siteConfig"; +import type { Product, WithContext } from "schema-dts"; + +import type { GQLProductJsonLdFragment } from "./ProductJsonLd.generated"; + +export const productJsonLdFragment = gql` + fragment ProductJsonLd on Product { + title + description + image + articleNumbers + inStock + price + priceRange { + min + } + manufacturer { + name + } + } +`; + +interface Props { + product: GQLProductJsonLdFragment; + scope: ContentScope; +} + +const maxWidth = 1200; + +function productImageUrl(image: DamImageBlockData, siteUrl: string): string | undefined { + const props = image.block?.props; + + if (!props) { + return undefined; + } + + if ("urlTemplate" in props && props.damFile?.image) { + const width = Math.min(props.damFile.image.width, maxWidth); + const aspectRatio = props.damFile.image.width / props.damFile.image.height; + return new URL(generateImageUrl({ src: props.urlTemplate, width }, aspectRatio), siteUrl).toString(); + } + + return props.damFile?.fileUrl ? new URL(props.damFile.fileUrl, siteUrl).toString() : undefined; +} + +export function ProductJsonLd({ product, scope }: Props) { + const siteConfig = getSiteConfigForDomain(scope.domain); + const image = productImageUrl(product.image, siteConfig.url); + const price = product.price ?? product.priceRange?.min; + + const data: WithContext = { + "@context": "https://schema.org", + "@type": "Product", + name: product.title, + ...(image ? { image } : {}), + ...(product.description ? { description: product.description } : {}), + ...(product.articleNumbers[0] ? { sku: product.articleNumbers[0] } : {}), + ...(product.manufacturer ? { brand: { "@type": "Brand", name: product.manufacturer.name } } : {}), + ...(price != null + ? { + offers: { + "@type": "Offer", + price, + // TODO: Read the currency from the site config once it carries one; the Demo only sells in EUR. + priceCurrency: "EUR", + availability: product.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock", + }, + } + : {}), + }; + + return data={data} />; +} From e6def43a069d10326189f07c63782466eb134ac7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 09:18:08 +0000 Subject: [PATCH 2/2] Demo: Add ItemList structured data to the Product List block Product list surfaces carried no structured data, so search and generative engines could not tell that the rendered list is an ordered collection of products. Emit an `ItemList` JSON-LD object from `ProductListBlock`, mirroring the `Product` structured data on the detail page. The loader resolves each product's site path to an absolute URL, which crawlers require for the `ListItem` entries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ASWsbyKgFo1v3SWsEHe2XT --- demo/site/src/products/ProductListJsonLd.tsx | 21 +++++++++++++++++++ .../blocks/ProductListBlock.loader.ts | 13 ++++++++---- .../src/products/blocks/ProductListBlock.tsx | 18 +++++++++------- 3 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 demo/site/src/products/ProductListJsonLd.tsx diff --git a/demo/site/src/products/ProductListJsonLd.tsx b/demo/site/src/products/ProductListJsonLd.tsx new file mode 100644 index 00000000000..360feae7a4a --- /dev/null +++ b/demo/site/src/products/ProductListJsonLd.tsx @@ -0,0 +1,21 @@ +import { JsonLd } from "@dextinity/site-nextjs"; +import type { ItemList, WithContext } from "schema-dts"; + +interface Props { + products: { title: string; url: string }[]; +} + +export function ProductListJsonLd({ products }: Props) { + const data: WithContext = { + "@context": "https://schema.org", + "@type": "ItemList", + itemListElement: products.map((product, index) => ({ + "@type": "ListItem", + position: index + 1, + name: product.title, + url: product.url, + })), + }; + + return data={data} />; +} diff --git a/demo/site/src/products/blocks/ProductListBlock.loader.ts b/demo/site/src/products/blocks/ProductListBlock.loader.ts index b2495ea79b4..188a08ea094 100644 --- a/demo/site/src/products/blocks/ProductListBlock.loader.ts +++ b/demo/site/src/products/blocks/ProductListBlock.loader.ts @@ -1,6 +1,7 @@ import { type BlockLoaderOptions, gql } from "@dextinity/site-nextjs"; import type { ProductListBlockData } from "@src/blocks.generated"; import { createSitePath } from "@src/util/createSitePath"; +import { getSiteConfigForDomain } from "@src/util/siteConfig"; import type { GQLProductListBlockQuery, GQLProductListBlockQueryVariables } from "./ProductListBlock.loader.generated"; @@ -28,8 +29,12 @@ export const loader = async ({ blockData, graphQLFetch, scope }: BlockLoaderOpti { ids: blockData.ids }, ); - return data.productsByIds.map((product) => ({ - ...product, - path: createSitePath({ scope, path: `/product/${product.slug}` }), - })); + const siteConfig = getSiteConfigForDomain(scope.domain); + + return data.productsByIds.map((product) => { + const path = createSitePath({ scope, path: `/product/${product.slug}` }); + + // The ItemList structured data requires absolute URLs. + return { ...product, path, url: new URL(path, siteConfig.url).toString() }; + }); }; diff --git a/demo/site/src/products/blocks/ProductListBlock.tsx b/demo/site/src/products/blocks/ProductListBlock.tsx index 721f8140b16..58c9f4bc140 100644 --- a/demo/site/src/products/blocks/ProductListBlock.tsx +++ b/demo/site/src/products/blocks/ProductListBlock.tsx @@ -1,5 +1,6 @@ import { type PropsWithData, withPreview } from "@dextinity/site-nextjs"; import type { ProductListBlockData } from "@src/blocks.generated"; +import { ProductListJsonLd } from "@src/products/ProductListJsonLd"; import Link from "next/link"; import type { LoadedData } from "./ProductListBlock.loader"; @@ -11,13 +12,16 @@ export const ProductListBlock = withPreview( } return ( -
    - {products.map((product) => ( -
  1. - {product.title} -
  2. - ))} -
+ <> + +
    + {products.map((product) => ( +
  1. + {product.title} +
  2. + ))} +
+ ); }, { label: "Product List" },