For months the Notifio site emitted an Organization node and a FAQPage node on most of its pages, and never once told a search engine that the thing it sells is an application you download and pay for. It described who we are and what we get asked, and stayed silent about the product.
SoftwareApplication is the type built for exactly this, and the whole implementation is one function. What is worth writing about is the four decisions inside it, because every one of them is a place where structured data quietly starts lying to you.
The function
import { LICENSE_PRICE_PENCE, PRICE_CURRENCY } from "@/lib/pricing/constants";
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",
},
};
}
Decision one: the price is imported, not typed
price: (LICENSE_PRICE_PENCE / 100).toFixed(2),
priceCurrency: PRICE_CURRENCY.toUpperCase(),
LICENSE_PRICE_PENCE is the same constant the API route passes to Stripe when it creates a Checkout Session. There is exactly one number, and the markup derives from it.
The alternative is price: "20.00" in a JSON-LD helper, which is correct on the day you write it and becomes a false advertisement the first time anyone runs a price change. That failure has a particularly nasty shape: the visible page updates because a component reads the constant, the structured data does not because nobody thought of it, and Google shows the old price in a rich result for as long as its cache lasts. The customer arrives having been quoted a price we no longer charge, which is the one kind of pricing error nobody forgives.
toFixed(2) because schema.org wants a decimal string, and pence divided by a hundred gives you 20 where you meant 20.00.
Decision two: the currency here is always GBP, even though the page is not
Notifio shows visitors an approximate price in their own currency. A visitor in Berlin sees euros, a visitor in Toronto sees Canadian dollars. We still charge in GBP, and I wrote up why that display layer rounds up rather than converting naively in we stopped letting Stripe convert our price.
This node deliberately does not participate in any of that. PRICE_CURRENCY is GBP, full stop, because an Offer is a commitment about what will be charged and not a courtesy conversion for the reader. It also has to agree with what a crawler sees in the prose, which it does, because the currency resolver returns the base currency for bots. That was its own bug once: Googlebot lives in a datacentre, and it was indexing the wrong currency.
The rule I would give anyone shipping localised prices: the visible number can be an approximation with a caveat next to it, and the structured data cannot, because structured data has nowhere to put the caveat.
Decision three: one @id, so nine templates describe one application
"@id": `${APP_URL}/#software`,
The node is emitted from the home page, the download page, the alerts hub and every per-site alerts page, the compare hub and every comparison page, the guides index, and every long-form article. That is a lot of pages asserting the existence of an application.
Because the @id is a fixed string rather than something built from the current URL, all of those assertions merge into one entity in a consumer's graph. Build it from the page URL instead and you have declared several dozen different applications that happen to share a name, which is how a site ends up with no product entity at all rather than one.
The same trick connects it to the publisher:
publisher: { "@id": `${APP_URL}/#organization` },
A reference, not a copy. The Organization node is defined once per page and pointed at from here, which is the pattern I wrote about in four sites, one publisher, and an @id that is not a link.
On an article page the result is a four node graph where each node knows its place:
const ld = {
"@context": "https://schema.org",
"@graph": [
breadcrumbListLd(crumbs, APP_URL),
softwareApplicationLd(),
{
"@type": "Article",
"@id": `${APP_URL}${href}#article`,
headline: article.h1,
author: { "@id": `${APP_URL}/#organization` },
publisher: { "@id": `${APP_URL}/#organization` },
mainEntityOfPage: `${APP_URL}${href}`,
},
{ "@type": "FAQPage", "@id": `${APP_URL}${href}#faq`, /* ... */ },
],
};
Page-specific nodes get page-specific ids with a fragment. Site-wide entities get the one stable id. Mixing those up is the most common structured data bug I see on sites that have otherwise done everything right.
Decision four: no aggregateRating, and a version that is honestly vague
The comment in the file says it plainly:
/**
* `@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.
*/
Star ratings are the most tempting field in the vocabulary, because they are the one that visibly changes how a result looks. They are also the field Google has a manual action for. We do not have a review system, so there is no honest number to put there, and a dishonest one risks the whole site's eligibility for rich results to win a row of stars on one.
softwareVersion: "1.x" is the same instinct in a smaller way. The exact version lives in the built installer and changes on a release cadence this file knows nothing about. Writing 1.4.2 here means shipping stale markup with every release and nobody noticing, whereas 1.x stays true and says roughly what someone wants to know.
operatingSystem: "macOS 12+, Windows 10+" has to match the words on the download page, and it does, because those numbers were copied from the same place the platform cards read from. It is also the field most likely to go stale on a project like this: we ship separate Apple Silicon and Intel builds, and the day we drop macOS 12 this string has to move with the installer matrix.
The download page carries the node on its own rather than inside a graph, for a reason worth stating:
{/* The page that hands over the installer is the one that should carry the
SoftwareApplication node: platform, price and downloadUrl in the markup
match what the page says in words. */}
Structured data that describes something the page does not visibly do is the definition of a markup mismatch. On /download every field in this node has a counterpart the reader can see: the platform cards, the price, the buttons.
What is still wrong
/pricing does not emit this node yet, which is the page the Offer url points at. Everything is internally consistent, so nothing is lying, but the one page dedicated to the offer is currently the page that does not describe it. That is next, and I would rather write it down than quietly fix it and pretend the first version was complete.
See it for yourself
View source on notifio.app/download and you will find this exact node in a single script tag. The version embedded in a graph alongside breadcrumbs and an article is visible on any guide, for example how fast do rental listings go, or on a per-site page such as Funda. The price the Offer claims is the price on notifio.app/pricing, and both come from one constant.
Top comments (0)