Blog/Web engineering
Schema.org structured data for B2B shops: prices behind logins, units and variants
Product markup for B2B shops: snippet or merchant listing, which price to publish, part numbers, unit pricing, variants, Organization and SSR in Nuxt.
Balázs Csorba··10 min read
- Schema.org
- Structured data
- B2B e-commerce
- Technical SEO
- Nuxt

Key takeaways
- Google has two product features: merchant listings need a purchasable page with a price above zero, product snippets do not.
- Only mark up what the visitor sees; a customer-specific price behind a login does not belong in public JSON-LD.
- Part numbers are the B2B advantage: put sku, mpn, gtin and brand in the markup, from the same PIM fields the page shows.
- Use UnitPriceSpecification with referenceQuantity for prices per metre, kilogram or pack, and ProductGroup for real variant families.
- Render JSON-LD on the server; Google warns that JavaScript-generated Product markup makes Shopping crawls less frequent and less reliable.
Most structured data guides are written for consumer shops: one public price, a star rating, free shipping. A B2B shop breaks most of those assumptions. Prices depend on the customer and often appear only after login, products are sold per metre or in packs of 50, a single article can have hundreds of variants, and the most valuable search terms are manufacturer part numbers rather than product names.
This post goes through the decisions I make when adding schema.org markup to B2B shops built on Nuxt with a commerce or PIM back end. It sticks to what Google documents for its product features, because that is the part with a public specification, and it shows where B2B data needs a different answer from the consumer default.
Why B2B product pages need different markup decisions
Structured data has one rule that drives nearly every B2B decision. Google’s general guidelines say: do not mark up content that is not visible to readers of the page. Markup is a description of the page, not a side channel for data you would rather not show. Everything below follows from asking, for each property, what an anonymous visitor and Googlebot actually see.
| B2B trait | Consumer default | B2B answer |
|---|---|---|
| Prices after login | Offer with public price | Snippet without price, or a public list price if one is shown |
| Sold per metre, kg or pack | Price per item | UnitPriceSpecification with referenceQuantity |
| Searched by part number | Name and brand | sku, mpn, gtin, brand from the PIM |
| Hundreds of variants | Size and colour | ProductGroup only for real SKU variants |
| Company identity matters | Logo and social links | Organization with legal name, VAT ID, return policy |
Product snippet or merchant listing
Google splits Product markup into two features. Product snippets are for pages where people cannot buy directly; they need a name plus at least one of review, aggregateRating or offers. Merchant listings are for pages where customers can buy from you; they need name, image and an offers object with price and priceCurrency, and unlike snippets they require a price greater than zero. Google notes that adding the merchant listing properties usually makes a page eligible for snippets as well.
For a B2B shop the choice follows from what the public page shows. If anonymous visitors see a list price and can order, or at least see what they would pay, aim for merchant listings. If the price says "log in to see your price", you are in snippet territory: mark up name, image, identifiers, brand and description, and leave offers out rather than inventing a number. B2B buyers rarely leave reviews in a shop, so in practice those pages often carry no rich result at all, and that is fine; the identifiers still help Google match the page to part-number searches.
Which price to publish, and how
If you publish a price, publish the one on the page, in the currency on the page, with the tax treatment the page shows. Many B2B shops display net prices to businesses; the markup simply repeats that number. Google recognises three kinds of price: the active price, a strikethrough price marked with priceType set to https://schema.org/StrikethroughPrice, and a member price marked with validForMemberTier. The two markers must not be combined in one price specification, and the active price carries neither.
Tiered prices, the "from 100 pieces" table on many B2B pages, have no dedicated Google feature. Schema.org offers eligibleQuantity on Offer and price specifications to express the quantity interval a price applies to, and eligibleCustomerType for the type of customer an offer is valid for. They are valid vocabulary, but Google’s merchant listing documentation does not list them, so I add them only when the PIM has clean data and expect no visible effect. The active price in the markup should be the single-unit price the page shows first.
Identifiers: the part B2B shops get right for free
B2B buyers search for 6ES7 214-1AG40-0XB0, not for "small PLC". Your PIM already holds the identifiers that make those searches work, so publish them: sku for your own article number, mpn for the manufacturer part number, gtin (or gtin8, gtin13, gtin14) where the product has one, and brand as a nested Brand. Google lists mpn as uniquely identifying the product for a given manufacturer. Show the same values in the visible product details; markup and page must agree.
Part numbers also drive internal search. If you are working on that side, semantic product search for B2B covers how exact identifiers and semantic retrieval fit together, and both depend on the same clean PIM fields.
Units, packs and prices per metre
Cable by the metre, screws in boxes of 200, granulate by the kilogram: unit pricing is everyday B2B. Google documents it for merchant listings and notes that it is particularly important in the EU, New Zealand and Australia for products sold by volume, length or weight. When the page shows a unit price, put the active price inside a UnitPriceSpecification and use referenceQuantity with a QuantitativeValue, where unitCode is a UN/CEFACT common code and an optional valueReference gives the base quantity.
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Control cable 5G1.5, grey, sold by the metre",
"image": "https://shop.example.at/media/cable-5g15.jpg",
"sku": "KAB-5G15-GR",
"mpn": "1119305",
"brand": { "@type": "Brand", "name": "ExampleCable" },
"offers": {
"@type": "Offer",
"url": "https://shop.example.at/p/kab-5g15-gr",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": 145.00,
"priceCurrency": "EUR",
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": 100,
"unitCode": "MTR",
"valueReference": { "@type": "QuantitativeValue", "value": 1, "unitCode": "MTR" }
}
}
}
}That example says: a 100-metre ring costs 145 euros, shown per metre. The page must display the same: price, ring length and the per-metre value. If your shop sells only full rings, say so in the name and description, because a buyer who expects to order 7 metres will bounce.
Variants without exploding the markup
For real variant families, sizes or colours with their own SKU, Google supports ProductGroup with hasVariant, variesBy and productGroupID. Each variant needs a unique ID such as sku or gtin, each group a unique ID, and each variant still needs the normal merchant listing properties. On single-page variant selectors there must be one canonical URL for the group, typically the page with no variant preselected. Google recommends nesting the variants under the ProductGroup as the most compact representation.
Configurable B2B products are a different case. A valve with 12 options and thousands of valid combinations is not a variant family; it is one product with a configurator. Mark it up as one Product with the starting price the page shows, and let the configurator, as described in headless B2B product configurators, handle the rest. Listing generated combinations as variants creates markup for products nobody can find a page for.
Organization, return and shipping policies
In B2B the seller matters as much as the product. Google’s Organization markup has no required properties and recommends adding as many relevant ones as you can. For a B2B merchant the useful ones are legalName, address, vatID or taxID, iso6523Code (Google encourages it with prefix 0199 instead of leiCode, and with prefix 0060 instead of duns), logo, contactPoint and sameAs. Put it once, site-wide, typically on the home or about page.
Return and shipping policies can now live at Organization level: hasMerchantReturnPolicy with a MerchantReturnPolicy, and hasShippingService for a shipping policy. Google recommends a global shipping policy under Organization instead of repeating shippingDetails on every offer, and keeps the offer-level properties for exceptions. B2B return rules are often stricter than consumer ones, with no statutory withdrawal right for businesses, so publish the policy you actually apply, with the real number of days and fee type.
Rendering JSON-LD in Nuxt on the server
Google can process structured data that JavaScript adds to the DOM, but its JavaScript guide has a specific warning for Product markup: dynamically generated markup can make Shopping crawls less frequent and less reliable, which hurts fast-changing data like price and availability. In Nuxt that means rendering the JSON-LD during SSR, from the same data the template uses. The Nuxt Schema.org module (built on Unhead) does this with useSchemaOrg and helpers such as defineProduct; the site identity goes into nuxt.config.ts.
<!-- pages/p/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
// anonymous price context only: never the logged-in customer's price list
const { data: p } = await useFetch(`/api/catalog/${route.params.slug}`, { query: { priceContext: 'public' } })
useSchemaOrg([
defineProduct({
name: p.value.name,
image: p.value.images.map(i => i.url),
sku: p.value.sku,
mpn: p.value.mpn,
...(p.value.gtin ? { gtin: p.value.gtin } : {}),
brand: { '@type': 'Brand', name: p.value.brand },
// publish an offer only when the page shows a public price
...(p.value.publicPrice ? {
offers: {
price: p.value.publicPrice.net,
priceCurrency: 'EUR',
availability: p.value.inStock ? 'https://schema.org/InStock' : 'https://schema.org/BackOrder',
},
} : {}),
}),
])
</script>
The important line is the price context. Fetch catalogue data for markup with the public price list explicitly, and make sure CDN and route caching never store a response rendered for a logged-in customer. Unhead does not check Google’s eligibility rules at runtime, so validation still belongs in your pipeline. If your shop has several languages, each locale should mark up its own URL and currency display, consistent with your hreflang and canonical setup.
One source of truth from PIM to markup
Markup errors in B2B shops are rarely schema errors. They are data errors: a GTIN field that holds an internal number, a price in the markup that comes from a different list than the one on the page, availability from yesterday’s ERP export. The fix is architectural: generate the visible page and the JSON-LD from the same API response, and fix data at the source rather than in a template.
This is the same discipline as any PIM-to-shop integration; if your product data arrives via delta syncs from SAP or Infor, the notes in Pimcore ERP delta sync apply directly, because a stale price in markup is just a stale price.
Testing and monitoring
Google’s own workflow is the Rich Results Test for individual URLs, then the URL Inspection tool to see how Google renders a deployed page, then the Search Console reports. For a shop with tens of thousands of products I add one automated layer that the tools do not provide: a CI job that renders a sample of product pages and compares the JSON-LD with the visible DOM.
- The markup price equals the visible price, and the currency matches.
- No offer is present on pages that show "log in for price".
skuandmpnappear in the visible product details.- GTIN values have a valid check digit; internal numbers in GTIN fields are a common PIM error.
- A page rendered with a logged-in cookie is never served from the shared cache.
Structured data will not rescue thin product pages, and it will not make a login-only catalogue rank like a consumer shop. What it does is describe your products in a format that search engines and other machines can parse without guessing. For the wider question of how AI systems read your pages, see my GEO audit and the overview of agentic commerce protocols, which build on the same clean product data.
Sources
- Google Search Central: Introduction to Product structured data
- Google Search Central: Merchant listing (Product, Offer) structured data
- Google Search Central: Product snippet structured data
- Google Search Central: Product variant structured data
- Google Search Central: Organization structured data
- Google Search Central: Merchant return policy structured data
- Google Search Central: General structured data guidelines
- Google Search Central: Generate structured data with JavaScript
- Schema.org: Offer
- Schema.org: UnitPriceSpecification
- Unhead: Product schema (defineProduct)
- Nuxt SEO: Schema.org identity setup
Frequently asked questions
Can a B2B shop with login-only prices use Product structured data?
Yes, but usually as product snippets rather than merchant listings. Product snippets need a name plus one of review, aggregateRating or offers. Merchant listings need an offer with a price greater than zero that the visitor can see, so a page that shows no price cannot qualify.
Which price should go into the markup?
The price that an anonymous visitor sees on the page, in the currency shown. Google requires the markup to describe visible content. Do not publish negotiated or customer-group prices that only appear after login.
How do I mark up a price per metre or per pack?
Put the active price inside a UnitPriceSpecification and add referenceQuantity with a value and a UN/CEFACT unitCode. Google documents this for unit pricing, which matters in the EU for goods sold by volume, length or weight.
Should I use ProductGroup for every product with options?
Use it when the options are real variants of one product, such as sizes or colours with their own SKU. Each variant needs a unique ID such as sku or gtin, and the group needs productGroupID. Configurable products with thousands of combinations are better described as one product with a starting offer.
Does structured data help AI assistants and answer engines?
Google documents what its own search features use. Other systems may read the same JSON-LD, but there is no public specification of how they weight it. Clean visible content and consistent identifiers are the part you control.