Getting enriched product data onto a headless storefront product pages
How an enriched product attribute travels from Shopify's Storefront API into a headless PDP template and shows up as real, crawlable HTML.

Once a product attribute is enriched, whether that's a spec, a use-case, or an identifier, it usually lands in a metafield or a PIM-fed field, not directly in a template. On a headless storefront there is no theme editor auto-binding that field to a section; a developer has to expose it through the API, query it in the route, and render it into markup that both a browser and a non-JS crawler can see. This guide walks through that path end to end on Shopify's Hydrogen storefront framework, since it's the most common current path to "headless" for retailers already on Shopify, and the same sequence (expose in API, query in a data loader, render server-side) applies conceptually to any headless commerce platform.
Where the enriched data actually lives
On Shopify, enriched attributes that don't fit a native product field (price, title, description) live as metafields, scoped to a namespace and key, for example specs.battery_life or specs.use_case. A metafield only becomes visible to a storefront-facing API once it has a metafield definition with its Storefront API access explicitly opened up. That's set on the definition itself:
mutation CreateSpecDefinition {
metafieldDefinitionCreate(
definition: {
name: "Battery life"
namespace: "specs"
key: "battery_life"
type: "single_line_text_field"
ownerType: PRODUCT
access: { storefront: PUBLIC_READ }
}
) {
createdDefinition {
id
}
userErrors {
field
message
}
}
}
As of API version 2025-01, Shopify removed the older metafieldStorefrontVisibilityCreate mutation; access.storefront: PUBLIC_READ on the definition is now the mechanism. Anything written to that metafield, whether by a merchant, a PIM sync job, or an enrichment tool, is invisible to the Storefront API until this step is done. This is worth checking first when a value shows up in the Shopify admin but not on the storefront: it's very often a definition-visibility gap, not a data problem (Shopify: Retrieve metafields with the Storefront API).
Querying it through the Storefront API
Once a definition is public, the Storefront API can return it on any product query, either as a single metafield(namespace, key) field or, more usefully for a PDP with several enriched attributes, as a list via metafields(identifiers: [...]):
query ProductWithSpecs($handle: String!) {
product(handle: $handle) {
id
title
metafields(
identifiers: [
{ namespace: "specs", key: "battery_life" }
{ namespace: "specs", key: "water_resistance" }
{ namespace: "specs", key: "use_case" }
]
) {
key
namespace
value
type
}
}
}
Note that the Storefront API is read-only for metafields: it can query them, but writing or updating them still has to go through the Admin API. That's the seam where a PIM or an enrichment pipeline pushes values in, and where the storefront pulls them out.
Binding the query to the product route
In current Hydrogen (built on React Router v7 and Vite as of 2026), the product page is a route file, typically app/routes/products.$handle.tsx, with a loader that runs server-side before the component renders and a component that reads the result. This is the binding step: the loader is what turns "a metafield exists in the API" into "data available to this template."
export async function loader({params, context}: Route.LoaderArgs) {
const {handle} = params;
const {storefront} = context;
const {product} = await storefront.query(PRODUCT_QUERY, {
variables: {handle},
});
if (!product?.id) {
throw new Response('Not found', {status: 404});
}
return {product};
}
export default function Product() {
const {product} = useLoaderData<typeof loader>();
const specs = (product.metafields ?? []).filter(Boolean);
return (
<section className="pdp">
<h1>{product.title}</h1>
<dl className="pdp-specs">
{specs.map((m) => (
<div key={`${m.namespace}.${m.key}`}>
<dt>{m.key.replace(/_/g, ' ')}</dt>
<dd>{m.value}</dd>
</div>
))}
</dl>
</section>
);
}
Because metafields(identifiers: ...) returns null entries for any identifier that doesn't resolve (wrong namespace, missing value, no storefront access), the filter(Boolean) is the difference between a clean spec list and a page full of blank rows (Shopify: Fetch Shopify API data in Hydrogen).
Making it readable to buyers and to AI agents
A dl/dt/dd list satisfies a human reader, but agents and crawlers that don't execute JavaScript, and a growing share of AI answer engines fall in that bucket, only see what the server actually sent in the HTML response. Because Hydrogen renders the loader's data server-side by default, the specs above are already present in that first response, not injected after hydration. Worth also emitting the same values as Product structured data, server-rendered in the same route, so both the visual PDP and the machine-readable layer come from one query instead of drifting apart:
const productJsonLd = {
'@context': 'https://schema.org/',
'@type': 'Product',
name: product.title,
additionalProperty: specs.map((m) => ({
'@type': 'PropertyValue',
name: m.key,
value: m.value,
})),
};
<script
type="application/ld+json"
dangerouslySetInnerHTML={{__html: JSON.stringify(productJsonLd)}}
/>
additionalProperty is the correct schema.org slot for attributes that don't map to a named Product field like color or material; it's the standard escape hatch for spec-style enrichment.
How to validate
- View-source vs. rendered DOM. Open the live product page, then view-source (or
curl) it. Compare that raw HTML against the "Elements" tab in browser devtools. If the spec values and the JSON-LD script tag appear in both, they're server-rendered and visible to non-JS crawlers. If they only appear in the Elements tab, they're being added client-side after hydration and most crawlers will miss them. - curl the route directly, no browser involved:
curl -s https://your-store.example.com/products/handle-name \
| grep -A2 "additionalProperty"
If that returns nothing, the JSON-LD isn't in the server response.
- Google's Rich Results Test. Paste the live URL into the Rich Results Test to confirm the
Producttype and its properties parse cleanly. It renders JavaScript, so it can pass even when something is client-injected, which is exactly why it should be a second check after view-source, not a replacement for it.
Verified as of July 2026 against Shopify's current Storefront API metafield-access model and Hydrogen's React Router v7-based architecture; menu paths and mutation names for metafield definitions are current as of API version 2025-01 and later.
The pattern above assumes the metafield already has a good value in it, which is the part that's hardest to sustain at catalog scale. Anglera enriches those specs, use-cases, and identifiers continuously against the source data and pushes them into whatever field your PIM or Shopify already uses, so the query and template above always have something accurate to return. It's additive on top of the PIM or commerce platform you already run: your PIM stores the data, Anglera does the work of keeping it right.
