Shopify Hydrogen

Add Lumio's enriched Product JSON-LD to a headless Hydrogen storefront with a copy-paste React component.

Last updated July 16, 2026

Lumio’s theme app extension only runs on Liquid (Online Store 2.0) themes. Headless storefronts don’t render Liquid, so Lumio delivers the same enriched structured data through a copy-paste React component that reads the enrichment metafield in the route loader.

Who this is for

Use this guide if the storefront is headless rather than a Liquid theme:

  • Hydrogen — Shopify’s React framework for headless storefronts, hosted on Oxygen or deployed to Vercel. Since the May 2025 release, Hydrogen is built on React Router 7 (earlier versions used Remix).
  • Pack Digital and Weaverse — CMS layers on top of a developer-owned Hydrogen codebase.
  • @shopify/hydrogen-react in Next.js or another React framework — the same component works; only the data wiring differs (see the note below).

If the storefront is a standard Liquid theme, use the theme extension instead — no code required.

Hydrogen support is available on the Elite and Enterprise plans. On those plans, Lumio’s Shopify push writes the storefront-readable metafield the component reads. See Plans & pricing.

Prerequisites

  • An Elite or Enterprise workspace. Hydrogen support is gated to those plans.
  • Shopify connected in Lumio (see Shopify integration).
  • At least one Push to Shopify run completed. That push writes the enrichment to a Storefront-readable metafield (namespace: "lumio", key: "enrichment") and creates the metafield definition that grants the Storefront API read access. Without a push, the metafield query returns null.

Step 1 — query the metafield in the loader

AI crawlers don’t execute JavaScript, so the structured data has to be in the server-rendered HTML. Hydrogen renders server-side by default, so the fix is to fetch the enrichment in the route loader — where product data is already loaded — rather than on the client.

Add the metafield to the product query in app/routes/products.$handle.tsx:

lumioEnrichment: metafield(namespace: "lumio", key: "enrichment") {
  value
}

In context, the loader looks like this:

// app/routes/products.$handle.tsx
import {useLoaderData} from 'react-router'; // Remix-era Hydrogen: '@remix-run/react'
import {LumioProductJsonLd} from '~/components/LumioProductJsonLd';

const PRODUCT_QUERY = `#graphql
  query Product($handle: String!) {
    product(handle: $handle) {
      id
      title
      description
      vendor
      availableForSale
      featuredImage { url }
      priceRange {
        minVariantPrice { amount currencyCode }
        maxVariantPrice { amount currencyCode }
      }
      selectedOrFirstAvailableVariant { sku barcode }
      # Lumio enrichment — merchant-owned, Storefront-readable metafield
      lumioEnrichment: metafield(namespace: "lumio", key: "enrichment") {
        value
      }
    }
  }
`;

export async function loader({params, context, request}) {
  const {product} = await context.storefront.query(PRODUCT_QUERY, {
    variables: {handle: params.handle},
  });
  const canonicalUrl =
    new URL(request.url).origin + `/products/${params.handle}`;
  return {product, canonicalUrl};
}

Reading app data through the Storefront API requires a metafield definition with storefront access set to PUBLIC_READ. Lumio creates that definition automatically during its Shopify push, so no manual metafield setup is needed. Reference: Shopify — metafields in the Storefront API.

Step 2 — add the component

Create app/components/LumioProductJsonLd.tsx. The component has no framework imports, so it works in any React-based Hydrogen setup:

/**
 * LumioProductJsonLd — server-rendered Product JSON-LD for Shopify headless
 * (Hydrogen / Oxygen / Pack Digital / Weaverse) storefronts.
 *
 * 1. Paste this file into `app/components/LumioProductJsonLd.tsx`.
 * 2. Render it inside your product route COMPONENT body — e.g.
 *      <LumioProductJsonLd product={product} url={canonicalUrl} />
 *    Do NOT emit it via meta / getSeoMeta (avoids a known CDN-minification
 *    hydration bug with head-level ld+json scripts).
 * 3. Query the metafield in the route LOADER (server-side). AI crawlers don't
 *    execute JavaScript, so a client-side fetch is invisible to them. Add to
 *    your product query:
 *
 *      lumioEnrichment: metafield(namespace: "lumio", key: "enrichment") { value }
 *
 * Pure React — zero framework imports — so it drops into any Hydrogen version.
 */

type MoneyV2 = { amount?: string | null; currencyCode?: string | null };

interface LumioProduct {
  title?: string | null;
  description?: string | null;
  vendor?: string | null;
  featuredImage?: { url?: string | null } | null;
  priceRange?: {
    minVariantPrice?: MoneyV2 | null;
    maxVariantPrice?: MoneyV2 | null;
  } | null;
  availableForSale?: boolean | null;
  selectedOrFirstAvailableVariant?: {
    sku?: string | null;
    barcode?: string | null;
  } | null;
  lumioEnrichment?: { value?: string | null } | null;
}

interface LumioEnrichment {
  name?: string | null;
  description?: string | null;
  brand?: string | null;
  category?: string | null;
  additionalProperty?: Array<{ name?: string; value?: string }>;
  faq?: Array<{ question?: string; answer?: string }>;
}

export function LumioProductJsonLd({
  product,
  url,
}: {
  product: LumioProduct;
  url?: string;
}) {
  let enrichment: LumioEnrichment | null = null;
  try {
    enrichment = product.lumioEnrichment?.value
      ? JSON.parse(product.lumioEnrichment.value)
      : null;
  } catch {
    enrichment = null;
  }

  const image = product.featuredImage?.url || undefined;
  const min = product.priceRange?.minVariantPrice;
  const max = product.priceRange?.maxVariantPrice;
  const availability = product.availableForSale
    ? "https://schema.org/InStock"
    : "https://schema.org/OutOfStock";

  // Live offers from the variant price range: a single Offer when the price is
  // fixed, an AggregateOffer when variants span a range.
  let offers: Record<string, unknown> | undefined;
  if (min?.amount != null && max?.amount != null) {
    offers =
      min.amount === max.amount
        ? {
            "@type": "Offer",
            price: min.amount,
            priceCurrency: min.currencyCode,
            availability,
            ...(url ? { url } : {}),
          }
        : {
            "@type": "AggregateOffer",
            lowPrice: min.amount,
            highPrice: max.amount,
            priceCurrency: min.currencyCode,
            availability,
            ...(url ? { url } : {}),
          };
  }

  const variant = product.selectedOrFirstAvailableVariant;
  const sku = enrichment && variant?.sku ? variant.sku : undefined;
  const gtin = enrichment && variant?.barcode ? variant.barcode : undefined;

  const jsonLd: Record<string, unknown> = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: enrichment?.name || product.title,
    description: enrichment?.description || product.description,
    ...(image ? { image } : {}),
    ...(url ? { url } : {}),
    brand: {
      "@type": "Brand",
      name: enrichment?.brand || product.vendor,
    },
    ...(enrichment?.category ? { category: enrichment.category } : {}),
    ...(sku ? { sku } : {}),
    ...(gtin ? { gtin } : {}),
    ...(offers ? { offers } : {}),
  };

  if (enrichment?.additionalProperty?.length) {
    jsonLd.additionalProperty = enrichment.additionalProperty.map((p) => ({
      "@type": "PropertyValue",
      name: p.name,
      value: p.value,
    }));
  }

  if (enrichment?.faq?.length) {
    jsonLd.hasFAQPage = {
      "@type": "FAQPage",
      mainEntity: enrichment.faq.map((q) => ({
        "@type": "Question",
        name: q.question,
        acceptedAnswer: { "@type": "Answer", text: q.answer },
      })),
    };
  }

  // Escape "<" so a product title/description containing "</script>" can't break
  // out of the JSON-LD <script> block (XSS). Merchant data is untrusted.
  const json = JSON.stringify(jsonLd).replace(/</g, "\\u003c");

  return (
    <script
      type="application/ld+json"
      data-lumio-jsonld="hydrogen"
      dangerouslySetInnerHTML={{ __html: json }}
    />
  );
}

When a product has no enrichment yet, the || fallbacks use the native Shopify fields, so the component always emits a valid Product block.

Step 3 — render it in the product route

Render the component in the product route’s component body, passing the product from the loader:

export default function Product() {
  const {product, canonicalUrl} = useLoaderData();
  return (
    <div>
      <LumioProductJsonLd product={product} url={canonicalUrl} />
      {/* ...existing product page markup... */}
    </div>
  );
}

The url prop is optional. When provided, it populates the JSON-LD url and the offers URLs; the component works without it.

Query the metafield in the loader, not on the client. AI crawlers read raw HTML without running JavaScript. Structured data injected from a client-side useEffect or fetch is invisible to them — it has to be server-rendered, which is why the metafield belongs in the route loader.

Next.js and hydrogen-react

The same component works with @shopify/hydrogen-react inside Next.js or another React framework. The only difference is the data wiring: fetch the product and its lumioEnrichment metafield server-side — in a Server Component, getServerSideProps, or an equivalent server data path — then pass the result to <LumioProductJsonLd product={product} url={canonicalUrl} />. Don’t move the fetch to the client.

Verifying it

  1. Load a product page and view page source. Search for data-lumio-jsonld="hydrogen" to find the emitted block.
  2. Validate the structured data with Google’s Rich Results Test — paste the product URL and confirm the Product type is detected.
  3. Metafield value changes can take time to reach storefront surfaces — up to about 90 minutes has been reported. If a freshly pushed enrichment hasn’t appeared yet, wait and re-check before troubleshooting.

Hydrogen generates its own robots.txt route. If it blocks AI user-agents like GPTBot, OAI-SearchBot, ClaudeBot, or PerplexityBot, those agents can’t fetch the page and won’t see the structured data at all. Confirm the crawlers aren’t disallowed.

Troubleshooting

  • The metafield query returns null. Confirm the workspace is on the Elite plan or higher — Hydrogen support is gated to Elite and Enterprise. Then run a Push to Shopify in Lumio: the push writes the lumio/enrichment metafield and creates the Storefront-readable definition. Until both are true, there’s nothing to read.
  • AI agents don’t see the structured data. Confirm the metafield is fetched in the route loader, not in a client-side useEffect or fetch. Client-injected JSON-LD doesn’t reach non-JS crawlers.