Notifio is a paid desktop app. One download, one price, and a website whose job is to say what it does and what it costs. Structured data matters more than usual for that, because the thing being described is a product with a price and a platform, which is exactly what SoftwareApplication exists for.
We emit one, from one function, on nearly every page:
/**
* The SoftwareApplication node for Notifio.
*
* A paid desktop download is exactly what this type is for, and the site was
* emitting an Organization and a FAQPage without ever saying the thing it
* actually sells is an application. Price, platform and category are the fields
* search engines surface, so all three are populated rather than left to be
* inferred from prose.
*
* `@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.
*/
export function softwareApplicationLd() {
return {
"@type": "SoftwareApplication",
"@id": `${APP_URL}/#software`,
name: "Notifio",
applicationCategory: "UtilitiesApplication",
applicationSubCategory: "Rental listing monitor",
operatingSystem: "macOS 12+, Windows 10+",
url: APP_URL,
downloadUrl: `${APP_URL}/download`,
softwareVersion: "1.x",
description:
"Desktop app that monitors rental listing search pages and sends an email the moment a new listing appears. One-time purchase, no subscription.",
publisher: { "@id": `${APP_URL}/#organization` },
offers: {
"@type": "Offer",
price: (LICENSE_PRICE_PENCE / 100).toFixed(2),
priceCurrency: PRICE_CURRENCY.toUpperCase(),
url: `${APP_URL}/pricing`,
availability: "https://schema.org/InStock",
category: "One-time purchase, lifetime licence",
},
};
}
I went counting call sites this week and found three things wrong with it, one of which is funny.
Thirty-three pages, and not the one that matters
The function is imported by eight modules. Two are single pages, five are dynamic routes, one is a shared article layout:
app/page.tsx 1 page
app/download/page.tsx 1
app/alerts/page.tsx 1
app/alerts/[site]/page.tsx 15
app/compare/page.tsx 1
app/compare/[target]/page.tsx 5
app/guides/page.tsx 1
components/ArticleLayout.tsx 8 (5 guides + 3 audience pages)
Thirty-three of the thirty-eight URLs in our sitemap. The five without it are /help, /about, /terms, /privacy and /pricing.
Four of those five are obviously fine. The fifth is the page the Offer node's own url field points at.
offers: {
url: `${APP_URL}/pricing`,
...
}
You can check it from a terminal. Thirty-three pages say "the offer for this application is at /pricing", and /pricing emits an Organization node and nothing else:
curl -s https://notifio.app/ | grep -c SoftwareApplication # 2
curl -s https://notifio.app/download | grep -c SoftwareApplication # 2
curl -s https://notifio.app/pricing | grep -c SoftwareApplication # 0
This is not fatal. @id is stable, so a crawler that has seen the node on any other page has the entity, and offers.url is a pointer rather than a claim that the target page re-declares it. But if you were going to pick one page on a software site where a parser should find a priced Offer in the markup, it would be the one called pricing, and it is the one page where there is none.
Why it probably happened
The pricing page is the only page on the site that is not statically rendered:
/**
* Rendered per request so the price is localised in the first frame.
*
* `getDisplayCurrency` reads the visitor's country from the request, which
* forfeits this page's static rendering. ...
*
* The metadata above stays in GBP, as does anything a crawler reads, that is
* the currency we actually charge.
*/
export const dynamic = "force-dynamic";
We charge GBP on every sale in every country. Visitors elsewhere are shown an approximate local figure so they do not have to convert in their head at the exact moment they are deciding whether to buy, and that conversion needs the request, which costs the page its static render.
So the page that states the price is also the page whose rendered price varies by visitor, and I think somebody, quite reasonably, did not want a structured-data price on a page like that. A crawler fetching from a datacentre in Virginia would pick up whatever currency that resolves to.
Except the Offer node does not use the display currency. It uses the constant:
price: (LICENSE_PRICE_PENCE / 100).toFixed(2),
priceCurrency: PRICE_CURRENCY.toUpperCase(),
LICENSE_PRICE_PENCE is 2000 and PRICE_CURRENCY is "gbp", in a module with no Stripe SDK behind it precisely so a marketing page can import a price without pulling a server-only dependency into its graph. The node would say "20.00" and "GBP" on the pricing page exactly as it does on the other thirty-three, no matter who fetched it. The caution was correct about the page and wrong about the node.
The price is right and the product is wrong
Second thing, and this one is a real inaccuracy rather than a placement quibble.
offers: {
"@type": "Offer",
price: "20.00",
category: "One-time purchase, lifetime licence",
}
One Offer. There are two products:
/** Base lifetime licence: monitoring and email alerts. One-time. */
export const LICENSE_PRICE_PENCE = 2000;
export const LICENSE_PRODUCT_NAME = "Notifio Lifetime License";
/**
* Auto-reply upgrade. Sits on top of the lifetime licence rather than replacing
* it, and is also one-time, there is no per-reply cost to cover, since replies
* are recorded flows replayed on the user's own machine.
*/
export const UPGRADE_PRICE_PENCE = 2000;
export const UPGRADE_PRODUCT_NAME = "Notifio Auto-Reply Upgrade";
Two separate one-time purchases at £20 each. The database has three columns for the second one. There are two checkout routes. The structured data imports one of the two constants and describes one of the two products.
The defence is that £20 is the entry price, it is true, and a search result showing "£20" for an app you can buy for £20 is not a lie. The problem is what the node is for. SoftwareApplication plus Offer is read by machines that want to answer "what does this cost", and the honest answer for the full product is £20 or £40 depending on what you want. Schema.org has the vocabulary for that: offers takes an array, and AggregateOffer has lowPrice and highPrice for exactly this shape.
That is the change I am actually going to make, and the reason is not SEO scoring. It is that we went to some trouble to keep two one-time payments instead of a subscription, and the machine-readable summary of our pricing currently does not mention the second half of it. You can see both on the pricing page, where a human reading prose gets the full picture that a parser does not.
The version that refuses to be a version
softwareVersion: "1.x",
Meanwhile app/package.json says:
"version": "1.1.0",
A hardcoded string in one repo, the real number in another. This one I am leaving, and I want to argue for it, because "two files disagree about the version" sounds like an obvious bug and is not.
The website and the desktop app ship on different schedules from different directories. A real version number in the JSON-LD would be stale from the moment the app released and the site did not, and there is no automation to keep them in step because there is no shared build. The options are a number that is sometimes wrong, a build step that exists only to feed one string into one structured-data field, or an honest statement of the major version.
"1.x" is the honest statement. Schema.org types softwareVersion as Text, not as a version, so "1.x" is a legal value, and it means what it says: this is the first major version, we are not telling you the patch. The field is not used for anything a user sees. Omitting it entirely would be defensible too, but then a validator complains about a missing recommended field and somebody fills it in with a guess, which is worse than a vague truth.
What is right in there
Worth saying, since the rest of this is faults.
publisher: { "@id": ... } is a reference rather than an inlined Organization. Every page that emits the software node also emits the organization node, so the reference resolves inside the same page's graph, and the alternative would be four duplicated fields on thirty-three pages describing an entity declared next to them.
The stable @id of /#software is what makes thirty-three copies one entity instead of thirty-three applications that happen to share a name.
And the missing aggregateRating is a deliberate absence with a comment explaining it. Rating markup is the single most tempting thing to invent on a product page, it is also the thing search engines issue manual actions over, and a comment saying "we do not collect these" is the only thing standing between a future me and five stars from nobody. The comparison pages have the same rule: they quote other companies' prices, so they carry a higher change frequency in the sitemap than their edit rate suggests, because a stale price about somebody else is worse than a stale sentence about us.
The pattern
All three faults are the same fault. The node is generated by one function with no parameters, which is the right design and means it cannot vary by page, and every mistake in it comes from a fact that does vary: which page is rendering, which product is being described, which version is shipped. A constant function is the correct tool for an entity that is genuinely constant, and we put two things in it that are not.
The app is at notifio.app and the download page is the other end of that downloadUrl field.
Top comments (0)