Product Schema for Inquiry-Only Products: What to Emit When There Is No Price
A product page with no price is not a broken product page - it is a different commercial model.
The markup answer is short: emit Product for the item itself, omit offers entirely, and describe the commercial terms you can actually state with additionalProperty plus a link to the quote path. Do not put 0, "N/A", or "price on request" into a price field.
The cost of that decision is real: these URLs will not be eligible for the product rich result, because that feature is built around an offer with a real price. Take the trade deliberately and write it down in the ticket, or someone will "fix" it in six months by inventing a number.
Here is the process I run on B2B catalogues.
Step 1 - inventory the URL types, not the products
You are deciding markup per template, not per SKU. List the templates:
/products/{slug}- one item, quote only/products/{family}- a family with variants/products/{category}- listing/resources/{slug}- datasheet, drawing, STEP file/contact,/request-a-quote- the form
Five templates, five decisions. If you have forty thousand SKUs you still only have five decisions.
Step 2 - pick a type per template
- Product detail, quote only:
Product, No offers - Product detail, genuinely public price:
Product, Yes - the real price only - Built-to-order / engineering service:
Service, Only if a real price exists - Family page with variants:
ProductGroup + hasVariant, Per variant - Category listing:
CollectionPage + ItemList, No - Datasheet or CAD download:
TechArticleorDigitalDocument, No - Quote form page:
ContactPage, No
The listing template is where people overreach. A category page is not a Product. It is a page that lists products, and ItemList with itemListElement pointing at the detail URLs says exactly that.
Step 3 - the minimum honest Product node
{
"@context": "https://schema.org",
"@type": "Product",
"@id": "https://example.com/products/xyz-650v#product",
"name": "XYZ-650V N-channel power MOSFET",
"sku": "XYZ-650V-TO247",
"mpn": "XYZ-650V",
"brand": {
"@type": "Brand",
"name": "Example Semiconductor"
},
"category": "Discrete semiconductors > MOSFETs",
"url": "https://example.com/products/xyz-650v",
"image": [
"https://example.com/media/xyz-650v-front.jpg",
"https://example.com/media/xyz-650v-package.jpg"
],
"description": "650 V N-channel power MOSFET in TO-247. Supplied against order; lead time and pricing are quoted per order quantity."
}
Notes on the fields that matter here:
skuvsmpn-skuis your catalogue identifier,mpnis the manufacturer's part number. A distributor usually has both and they differ. Emitting your internal SKU asmpnmakes cross-reference systems match the wrong part.image- absolute URLs that return an image. Not a relative path, not adata:URI, not a placeholder.description- put the commercial reality in words: "quoted per order quantity". Other systems read this string, including the LLM-backed ones.@id- a stable fragment per entity. You will reference it from other nodes, and duplicate@idvalues across blocks are how you end up with two contradictory Products on one page.
Step 4 - put the terms you can state into additionalProperty
PropertyValue is the honest place for the commercial facts that are not prices.
{
"@context": "https://schema.org",
"@type": "Product",
"@id": "https://example.com/products/xyz-650v#product",
"name": "XYZ-650V N-channel power MOSFET",
"sku": "XYZ-650V-TO247",
"additionalProperty": [
{
"@type": "PropertyValue",
"name": "Drain-source voltage",
"value": "650",
"unitText": "V"
},
{
"@type": "PropertyValue",
"name": "Drain current",
"value": "40",
"unitText": "A"
},
{
"@type": "PropertyValue",
"name": "Package",
"value": "TO-247"
},
{
"@type": "PropertyValue",
"name": "Minimum order quantity",
"value": "500",
"unitText": "pieces"
},
{
"@type": "PropertyValue",
"name": "Quoted lead time",
"value": "8-10",
"unitText": "weeks"
}
]
}
Rule: every property here must be visible on the page. Markup that contradicts the page is worse than no markup, because you have now made a machine-readable claim you cannot defend.
MOQ and lead time are the two most useful entries for B2B. They answer the first question a buyer has after "how much", and they are exactly the facts a chat agent or an AI answer engine needs to avoid saying "contact us for details" forever.
Step 5 - make "how to ask" machine-readable
Site-wide organization node, once, on every page:
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://example.com/#organization",
"name": "Example Semiconductor",
"url": "https://example.com/",
"contactPoint": [
{
"@type": "ContactPoint",
"contactType": "sales",
"email": "s****@example.com",
"availableLanguage": ["en"],
"areaServed": ["US", "DE", "SG"]
}
]
}
On the product page, reference it rather than repeating it:
{
"@context": "https://schema.org",
"@type": "Product",
"@id": "https://example.com/products/xyz-650v#product",
"name": "XYZ-650V N-channel power MOSFET",
"manufacturer": {
"@id": "https://example.com/#organization"
}
}
Optionally, point at the quote path:
"potentialAction": {
"@type": "CommunicateAction",
"name": "Request a quote",
"target": "https://example.com/request-a-quote?part=XYZ-650V"
}
Be honest about what this does: no search feature renders it today. It exists so that anything parsing the page - including agent-style readers - can find the path from a part number to a human. If that is not a goal you have, drop it and keep the node smaller.
Step 6 - the cases where an offer is correct
If the price is genuinely public and printed on the page, emit it, and make sure the two match exactly.
{
"@type": "Offer",
"price": "0.42",
"priceCurrency": "USD",
"url": "https://example.com/products/xyz-650v",
"eligibleQuantity": {
"@type": "QuantitativeValue",
"minValue": 10000,
"unitText": "pieces"
}
}
Tiered pricing: either emit the real entry price with the quantity condition attached, as above, or emit nothing. Never emit AggregateOffer with lowPrice: "0" to mean "call us" - zero is a number, and it will be read as one.
Step 7 - variants
A family page is not five Products scattered in five blocks. It is one group with variants:
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"@id": "https://example.com/products/xyz-family#group",
"name": "XYZ 650 V MOSFET family",
"productGroupID": "XYZ-650V",
"variesBy": ["https://schema.org/model"],
"hasVariant": [
{
"@type": "Product",
"@id": "https://example.com/products/xyz-650v-to247#product",
"name": "XYZ-650V in TO-247",
"sku": "XYZ-650V-TO247",
"isVariantOf": {
"@id": "https://example.com/products/xyz-family#group"
}
},
{
"@type": "Product",
"@id": "https://example.com/products/xyz-650v-d2pak#product",
"name": "XYZ-650V in D2PAK",
"sku": "XYZ-650V-D2PAK",
"isVariantOf": {
"@id": "https://example.com/products/xyz-family#group"
}
}
]
}
Each variant still keeps its own URL, so each variant page emits its own Product node. The family page emits the group. No node appears twice, and every @id is unique.
Step 8 - validate, and define what "pass" means
Three checks, and they answer different questions:
- Rich Results Test - will Google show a rich result. For inquiry-only pages, expect
Productto be detected and merchant-listing eligibility to be absent. That is the expected result, not a defect. - Schema Markup Validator - is the vocabulary valid. This is where you catch
PropertyValuetypos and wrong@typenames. - Search Console → Enhancements - you may see no product report entries at all for these URLs. Also expected.
Write the expected outcome into the ticket. The most common regression I see is a later "SEO fix" that adds "price": "0" because a crawler report looked empty.
A quick local check that every page emits exactly one Product node and that the JSON parses:
// node check-jsonld.mjs https://example.com/products/xyz-650v
const url = process.argv[2];
const html = await (await fetch(url)).text();
const blocks = [...html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)];
console.log(`${blocks.length} JSON-LD block(s)`);
const types = [];
for (const [, raw] of blocks) {
let data;
try {
data = JSON.parse(raw.trim());
} catch (err) {
console.log('! invalid JSON: ', err.message);
continue;
}
const walk = (node) => {
if (Array.isArray(node)) return node.forEach(walk);
if (node && typeof node === 'object') {
if (node['@type']) types.push(node['@type']);
Object.values(node).forEach(walk);
}
};
walk(data);
}
console.log('types: ', types.join(', '));
console.log('Product nodes: ', types.filter((t) => t === 'Product').length);
Run it against ten representative URLs in CI. Two Product nodes on one page is the failure mode that actually costs money.
Failure list
"price": "0","price": "N/A", orpriceCurrencywith no price.aggregateRatingyou do not have. It is the fastest way to get markup ignored site-wide.availability: InStockon a made-to-order item.AggregateOfferwithlowPrice: 0standing in for "contact sales".- Relative or
data:image URLs. - JSON built by string concatenation. One
"inside a product description and the block stops parsing. Use a JSON encoder. - Stale markup left behind after a re-platform, now describing products that no longer exist.
- A plugin emitting
Productwhile your template also emitsProduct, with different@idvalues, on the same URL.
The short version
- Product detail, quote only:
Product, no offers. - Put MOQ, lead time and real technical attributes in
additionalProperty. - One organization node site-wide with a
contactPointof typesales. - Variants via
ProductGroup + hasVariant, unique@idper node. - Accept the lost rich result, and record that decision.
Disclosure: I work at AumCreate. We ship this markup on WordPress with AumViso, a free plugin that also handles llms.txt, sitemaps, meta and internal links - the schema module is the part relevant to this article.
Comments
No comments yet. Start the discussion.