DEV Community

Cover image for Product Schema for Inquiry-Only Products: What to Emit When There Is No Price
AumCreate
AumCreate

Posted on

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

Template Primary type offers?
Product detail, quote only Product No
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 TechArticle or DigitalDocument 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."
}
Enter fullscreen mode Exit fullscreen mode

Notes on the fields that matter here:

  • sku vs mpn — sku is your catalogue identifier, mpn is the manufacturer's part number. A distributor usually has both and they differ. Emitting your internal SKU as mpn makes cross-reference systems match the wrong part.
  • image — absolute URLs that return an image. Not a relative path, not a data: 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 @id values 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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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": "sales@example.com",
      "availableLanguage": ["en"],
      "areaServed": ["US", "DE", "SG"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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" }
}
Enter fullscreen mode Exit fullscreen mode

Optionally, point at the quote path:

"potentialAction": {
  "@type": "CommunicateAction",
  "name": "Request a quote",
  "target": "https://example.com/request-a-quote?part=XYZ-650V"
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 Product to 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 PropertyValue typos and wrong @type names.
  • 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);
Enter fullscreen mode Exit fullscreen mode

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", or priceCurrency with no price.
  • aggregateRating you do not have. It is the fastest way to get markup ignored site-wide.
  • availability: InStock on a made-to-order item.
  • AggregateOffer with lowPrice: 0 standing 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 Product while your template also emits Product, with different @id values, on the same URL.

The short version

  1. Product detail, quote only: Product, no offers.
  2. Put MOQ, lead time and real technical attributes in additionalProperty.
  3. One organization node site-wide with a contactPoint of type sales.
  4. Variants via ProductGroup + hasVariant, unique @id per node.
  5. 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.

Top comments (0)