“How much does it cost?” is one of the clearest questions a page can answer, and pricing pages are often the worst-built pages on a site for answering it. The price sits in a designed graphic, the plan grid is a stack of styled <div>s, the monthly/annual toggle swaps numbers with JavaScript, and the date the prices took effect appears nowhere. A person reading the page gets the answer. A system reading the HTML may get a heading that says “Simple, transparent pricing” and very little else.
This post covers the five parts that decide whether a pricing page’s numbers survive extraction: the price as text, Offer markup that states the same price, a real comparison table, dated price changes, and a billing FAQ. It sits under Answer Engine Optimization: a page-level guide, and it is the commercial sibling of product pages that answer engines can quote.
Why do pricing pages fail extraction?
Pricing pages fail because they are designed for scanning by eye, and the design choices that help a person scan are the ones that hide the numbers from a parser. Four patterns account for most of it:
| Pattern | What a person sees | What the HTML holds |
|---|---|---|
| Price rendered as an image or SVG | ”$49 / month” | An <img> with, at best, alt text |
| Monthly/annual toggle | The price for the selected period | One period in the HTML, the other injected by script, or both with one hidden |
Plan grid built from <div>s | Columns of plans, rows of features | A flat run of text with no row or column relationships |
| ”Contact sales” for every tier | A clear call to action | No number at all |
Fix the first two first: they decide whether a number exists in the response at all. Google’s guidance for AI features lists “making sure that important content is available in textual form” among the SEO basics that carry over to AI Overviews and AI Mode (Google Search Central). A price is about as important as content gets on this page.
If annual prices exist only after a click runs a script, then for any reader that does not run scripts, your annual price does not exist. JavaScript content and what never makes the trip covers the general case.
How do you put the price in the text?
Write each price as a sentence-ready string in the HTML, with its currency, its unit, and its billing period next to it. “$49 per user per month, billed annually” is a liftable fact. “49” in one <span>, ”$” in a pseudo-element, and “/mo” in a third element styled to sit next to them is a layout.
The rules that make a price survive on its own:
- Currency in the text, not in CSS. A currency symbol added with
::beforeis not in the document text. - Unit and period with the number. Per user, per seat, per site, per month, per year, one-time. A price without its unit is how a correct number becomes a wrong answer.
- Both billing periods present in the HTML. If the toggle is kept, render both prices server-side and let the toggle switch visibility. Better still, state both in one line: “$49 per month billed monthly, or $490 per year billed annually.”
- One sentence per plan that stands alone. Something like “The Team plan costs $49 per user per month and includes up to 20 users.” That sentence can be lifted without the grid around it. Numbers, units and dates that survive extraction covers the formatting of the figures themselves.
Check it by hand. Open view-source: on the pricing page and search for each price, monthly and annual, with its currency symbol. If one is missing from the source, the page fails before markup comes into it. Then select all on the rendered page and paste into a plain-text editor. A price missing from that paste is not text.
What Offer markup should a pricing page carry?
Mark up the prices with schema.org Offer, and make the markup state exactly what the visible text states. Google’s structured data policies say: “Don’t mark up content that is not visible to readers of the page” (Google Search Central). Its AI features page asks for structured data that “matches the visible text on the page” (Google Search Central). Markup that disagrees with the page is worse than none: now the page carries two prices.
The properties that matter, from Google’s product snippet documentation (Google Search Central):
| Property | What it states | Google’s note |
|---|---|---|
price | The number, without a currency symbol | Required for an Offer. Can sit on the Offer or inside priceSpecification; if both are present, Google uses offers.price |
priceCurrency | ISO 4217 code, such as USD | Recommended for product snippets, required for merchant listings, “best to always provide” |
priceValidUntil | The date the price stops applying | A snippet “may not display” if this date is in the past |
priceSpecification | A UnitPriceSpecification for complex prices | Google links to its merchant listing pricing examples for complex cases |
Subscription prices need one more layer. Schema.org’s UnitPriceSpecification has billingDuration (“for how long this price (or price component) will be billed”), referenceQuantity for per-unit prices such as per seat, and unitCode or unitText for the unit itself (schema.org). Google’s product snippet page doesn’t document how it treats them, so treat them as description, not a feature switch.
A minimal block for one plan, on a fictional product:
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Acme Analytics Team plan",
"offers": {
"@type": "Offer",
"price": 490,
"priceCurrency": "USD",
"priceValidUntil": "2027-03-31",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": 490,
"priceCurrency": "USD",
"referenceQuantity": { "@type": "QuantitativeValue", "value": 1, "unitText": "user" },
"billingDuration": "P1Y"
}
}
}
Two cautions from Google’s documentation. Product markup belongs in the initial HTML: Google says “dynamically-generated markup can make Shopping crawls less frequent and less reliable, which can be an issue for fast-changing content like product availability and price.” And if you sell in several currencies, Google asks for “a distinct URL per currency,” not one page that swaps the symbol by visitor location. The schema stack that matters for AEO and GEO covers where Product and Offer sit relative to the other types.
Check it by hand. Paste the URL into a structured-data validator and compare each Offer value against the visible price for the same plan and period. Any mismatch is a finding, as is a priceValidUntil already passed.
How should a plan comparison table be built?
Build the plan comparison as an HTML <table>, plans as column headers and features as row headers, because only that markup tells a parser which cell belongs to which plan. A <div> grid flattens into “Starter Team Enterprise 5 users 20 users Unlimited.” Tables and lists that survive extraction sets out the general method. Three points are specific to pricing grids:
- The price row is a row. Put each plan’s price in the table, not only in the cards above it.
- Ticks need words. A check-mark icon in a cell carries nothing once it’s extracted. Write “Included,” “Not included,” or the limit (“Up to 20 users”).
- Introduce the table with a sentence. “The three plans differ in user limits, support hours, and data retention:” gives the table a subject when it’s lifted out of the page.
Why should price changes be dated?
Date your prices because an undated price can’t be checked against the present. An index that captured your page last spring holds last spring’s number. A visible line such as “Prices effective 1 March 2026” lets any reader tell whether a quoted figure is current.
Keep three dates honest and consistent:
- The visible effective date next to the prices.
priceValidUntilin the Offer, where the price has a known end, andvalidFromon the price specification where it has a known start. Schema.org definesvalidFromas “the date when the item becomes valid” (schema.org).- The page’s
dateModified, which should move when a price changes and not otherwise. Freshness signals and honest dateModified covers the rules.
When a price changes, keep a short dated changelog: “On 1 March 2026 the Team plan moved from $39 to $49 per user per month. Existing annual subscriptions keep their price until renewal” (illustrative). It answers the question people ask after a change: does it affect me?
What belongs in a billing FAQ?
A billing FAQ belongs on the pricing page when it answers questions buyers actually ask: refunds, trials, renewals, tax, seat counting, and cancellation. Pull them from support tickets and sales calls, not a template.
Each answer opens with the answer, “Yes. Annual plans can be refunded within 30 days of purchase” (illustrative), and states its numbers in full rather than pointing back to the grid. Mark it up with FAQPage if you like, but understand what that is. Google’s changelog records that the FAQ rich result “will no longer appear in Google Search starting May 7, 2026” (Google Search Central). The markup describes the Q&A pairs as entities. It is not a route to a rich result, and FAQ sections: leverage or filler covers when the section earns its place at all.
How do you check pricing pages across a site?
Without a terminal, use a crawler’s custom extraction and a spreadsheet. Many sites have dozens of pricing surfaces: per-product, regional, and plan detail pages. Add these rules:
- Visible prices: a regex against the rendered text, such as
[$€£]\s?[0-9][0-9,.]*, returning all matches. - JSON-LD prices: a regex against the source:
"price"\s*:\s*"?([0-9.]+). - Validity date:
"priceValidUntil"\s*:\s*"([^"]+)". - Real tables:
count(//main//table). - Price images:
//main//img[contains(translate(@src,'PRICE','price'),'price')]/@src, which is a rough flag for prices drawn as graphics.
Export, then compare in the sheet. With the JSON-LD price in C and the visible text matches in D, =ISNUMBER(SEARCH(C2, D2)) flags pages whose marked-up price does not appear in the visible text. =DATEVALUE(LEFT(E2,10)) < TODAY() flags expired priceValidUntil dates. A zero in the table column on a page with more than one plan is a grid to rebuild.
In a terminal, the same first look for one URL:
curl -s "$URL" | grep -oE '"price(Currency|ValidUntil)?"\s*:\s*"?[^",}]+'
curl -s "$URL" | sed 's/<[^>]*>/ /g' | grep -oE '[$€£] ?[0-9][0-9,.]*' | sort -u
Both read the raw response, so a price that appears only after scripts run will be missing. That gap is the finding.
GEOBee reports the structured-data state of every URL. Product is one of the types it detects, and it validates required fields, including that a Product has a name and offers; missing or incomplete markup reads as None, Invalid, or Partial. Product also counts toward the AEO lens’s answer-relevant schema signal (the three lenses lists the weight). Whether the markup’s price matches the visible one is still the spreadsheet comparison above.
To see which pricing and plan pages on your site carry no Offer markup or only partial markup, download GEOBee and crawl them. The 7-day trial exports every finding with its fix.