Notifio is a desktop app that watches rental search pages. Its website is 38 URLs, and I have spent a lot of posts on the metadata those URLs emit without ever auditing the structured data as a whole. Yesterday I counted the internal link graph from the rendered HTML rather than from the source and found something the source-level count could not see, so I pointed the same approach at the JSON-LD.
Fetch all 38, pull every <script type="application/ld+json">, flatten @graph, count @type.
What is actually on the site
@type frequency across 38 pages:
38 Organization
33 SoftwareApplication
31 BreadcrumbList
30 FAQPage
8 Article
3 ItemList
1 AboutPage
The first line is the one I am pleased about. Organization is on every page, from the root layout, and every instance carries @id of https://notifio.app/#organization with a byte-identical body. One entity, declared 38 times, merging cleanly. That node is assembled by the file copied into five sibling repositories, and the discipline in it has held.
SoftwareApplication does the same thing on 33 pages. The helper says why:
/**
* `@id` is stable so that every page emitting this node merges into one entity
* rather than declaring a new application per URL. No `aggregateRating`: we do
* not collect ratings, and inventing one is a manual action waiting to happen.
*/
And it works. All 33 bodies are identical apart from /download, which carries an extra "@context" key because the node sits at the top level of its own script tag there rather than inside a @graph. That is a structural difference, not a contradiction, and it changes nothing about how the node merges. I checked specifically because two different bodies under one @id would have been the real bug, and it is not one.
So the identity discipline is fine. The gap is about which pages opt in.
The five pages without it
/pricing 1 node: Organization
/terms 1 node: Organization
/privacy 1 node: Organization
/help 2 nodes: Organization, FAQPage
/about 2 nodes: Organization, AboutPage
Four of those five are correct. Terms, privacy, help and about are not pages about the product as a purchasable application, and bolting an Offer onto a privacy notice would be noise.
/pricing is the page where you buy it.
And here is the Offer that 33 other pages are emitting:
"offers": {
"@type": "Offer",
"price": "20.00",
"priceCurrency": "GBP",
"url": "https://notifio.app/pricing",
"availability": "https://schema.org/InStock",
"category": "One-time purchase, lifetime licence"
}
"url": "https://notifio.app/pricing". That field means "the page where this offer can be acted on". Thirty-three pages tell a crawler that the offer lives at /pricing, and /pricing tells the crawler nothing except who the publisher is. The destination of every Offer on the site is the one page on the site with no Offer on it.
It is a missing import, not a decision
$ grep -rln "softwareApplicationLd" server/src/app server/src/components
server/src/app/alerts/[site]/page.tsx
server/src/app/alerts/page.tsx
server/src/app/compare/[target]/page.tsx
server/src/app/compare/page.tsx
server/src/app/download/page.tsx
server/src/app/guides/page.tsx
server/src/app/page.tsx
server/src/components/ArticleLayout.tsx
Eight importers. The seven routes plus ArticleLayout, which is what gives the eight guide and audience pages theirs. /pricing is not in the list, and the pricing page emits no JSON-LD of its own at all:
$ grep -c "ld+json\|schema.org" server/src/app/pricing/page.tsx
0
I can construct a reason this might have been deliberate. /pricing is force-dynamic because it quotes the visitor's own currency, and a crawler gets pounds while a visitor in Amsterdam gets euros. Emitting a hardcoded GBP Offer on a page rendering euro figures sounds like the sort of mismatch that gets flagged.
That reason does not survive contact with the other 33 pages. The home page is also force-dynamic, also shows local currency, and emits the GBP Offer anyway, on purpose, because the Offer is an identity for the product rather than a quote for this visitor. Whatever the argument is, it has already been made and settled everywhere except on the page it most applies to. Nobody chose to leave /pricing out. The import is just not there.
The version string is "1.x"
While I had the node open:
"softwareVersion": "1.x",
"operatingSystem": "macOS 12+, Windows 10+",
This morning I published what our three installers actually are: the Mac DMGs are version 1.1.0 built on 22 September, and the Windows installer is version 1.0.3 built on 28 July. Nothing in the app reads app.getVersion(), so there is no surface anywhere in the product that states a version.
Except this one. softwareVersion: "1.x" is the only version claim on the entire property, and it is a glob. I wrote it that way to avoid having to update structured data on every release, which was lazy and, as it turns out, lucky: "1.x" is the only string that is simultaneously true of the 1.1.0 our Mac users download and the 1.0.3 our Windows users download. A real version number in that field would have been wrong for at least one platform since September.
operatingSystem has the same quality. One string covering both, so it cannot be wrong about either, and cannot tell you that one of them is three months stale.
30 FAQPage blocks, and what they are worth now
Thirty of the 38 pages carry a FAQPage. I have written about the array that feeds both the visible FAQ and the markup, which is the right structure: the markup cannot drift from the page because it is the page.
What I should say plainly is that in 2023 Google restricted FAQ rich results to well-known authoritative government and health sites. We are a one-person desktop app. None of these 30 blocks produces a rich result in Google and none of them has since before the site existed.
I am keeping them, and I want to be honest that this is a judgement call rather than a win. The markup is true, it describes the page accurately, Google is not the only consumer of schema.org, and the cost is a few hundred bytes on pages that already ship the same text visibly. But anyone reading an audit like this one should know that 30 of the 38 numbers in that frequency table are not buying what the type was originally added for.
What I am changing
One import on /pricing, which makes the Offer's destination describe the offer. The GBP figure stays, because it is the product's list price rather than a quote, and that argument is already settled across 33 pages.
Then the check, which is the part I keep arriving at from different directions. The link graph needed a test. The sibling arrays needed a test. This needs the same one, and it is the easiest of the three: build every route, parse the JSON-LD, and assert that any page emitting an Offer whose url is /pricing has a counterpart at /pricing. That is a property of the output, so it can be asserted about the output, and it would have failed the day the pricing page was written.
Every number above came from fetching the 38 URLs in our sitemap and parsing the script tags. The pricing page is the quickest one to check: view source and count the ld+json blocks.
Top comments (0)