Product Schema for Inquiry-Only Products: What to Emit When There Is No Price
DEV Community

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: 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."
}

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"
    }
  ]
}

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 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);

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

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

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.