Guide ·
Structured data for a software product: the JSON-LD we ship
Structured data tells a search engine what a page is about without leaving it to guess: the product, what it costs, who makes it. Here is the JSON-LD on Basalt Studio’s own sites, copied from the live pages on 21 Oct 2026, a terminal check for each block, and the mistake Search Console caught that day.
What it does
Clearer pages, not higher rankings
JSON-LD is a block of schema.org data in a <script type="application/ld+json"> tag. Google uses it to understand a page and to decide whether the page can have a rich result, such as a price or a breadcrumb trail in place of the address. It does not move a page up, and Google says it does not guarantee that a rich result will show.
Two things in Google’s documentation, read on 21 Oct 2026, set expectations for a software product. The software app rich result needs a rating or a review as well as a price. None of our apps has ratings to mark up, so none of them is eligible, and we do not invent ratings to get one. And the FAQ rich result no longer appears at all (step 4).
The blocks
Six steps, from the product to the build check
Each step has what to add, a command that checks it on a live page, and what is on our sites.
Describe the product: SoftwareApplication or WebApplication, with an offer
- Do
On the home page, one
SoftwareApplicationblock for an app people install, orWebApplicationfor one that runs in the browser. Give it the name, a one-line description,applicationCategory,operatingSystemand anoffersblock withpriceandpriceCurrency. A free product has a price of 0. Keep every value the same as the visible page.- Check
Set the page’s address in
page. The first command prints the types in each JSON-LD block on the page, one block per line, and stops with an error on a block that is not valid JSON. The second prints the offer and app fields. Both use curl, grep and perl, which come with macOS and most Linux systems.page=https://example.com/curl -s "$page" | perl -MJSON::PP -0777 -ne 'for (/<script[^>]*ld\+json[^>]*>(.*?)<\/script>/gs) { my $d = JSON::PP->new->decode($_); print join(" ", map { $_->{"\@type"} } @{ $d->{"\@graph"} || [$d] }), "\n" }' curl -s "$page" | grep -o -E '"(price|priceCurrency|applicationCategory|operatingSystem)": ?"?[^,}"]*"?'- On our sites
Upright, our free Mac app, from its live home page on 21 Oct 2026, trimmed to these fields:
{ "@context": "https://schema.org", "@type": "SoftwareApplication", "name": "Upright", "applicationCategory": "UtilitiesApplication", "operatingSystem": "macOS 13 or later", "softwareVersion": "1.1.2", "license": "https://opensource.org/licenses/MIT", "isAccessibleForFree": true, "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, "publisher": { "@type": "Organization", "@id": "https://hellobasalt.dev/#org", "name": "Basalt Studio", "url": "https://hellobasalt.dev/", "sameAs": ["https://x.com/hellobasalt", "https://www.youtube.com/@hellobasalt"] } }The check printed:
SoftwareApplication WebSite FAQPage "applicationCategory": "UtilitiesApplication" "operatingSystem": "macOS 13 or later" "price": "0" "priceCurrency": "USD"Kaç Papel, our Turkish price game, runs in the browser, so it is a
WebApplication. Its first structured data went in on 19 Oct (kacpapel PR #12); before that, 0 of 6 sampled pages had any JSON-LD. Its home page now has, trimmed:{ "@type": "WebApplication", "@id": "https://kacpapel.app/#app", "name": "Kaç Papel?", "inLanguage": "tr", "applicationCategory": "GameApplication", "operatingSystem": "Any", "isAccessibleForFree": true, "offers": { "@type": "Offer", "price": 0, "priceCurrency": "TRY" }, "publisher": { "@id": "https://hellobasalt.dev/#org" } }and the check printed:
WebSite WebApplication Organization "applicationCategory":"GameApplication" "operatingSystem":"Any" "price":0 "priceCurrency":"TRY"
Name the maker once, and point every publisher at it
- Do
Describe your company once, as an
Organizationwith an@id, a name, a URL andsameAslinks to its profiles. Point every product’spublisherat that@id. The same@idon every site tells a search engine that the products have one maker.- Check
This counts the
@ids ending in#orgon a page. Run it on each of your sites: the same address should come back on all of them.curl -s "$page" | grep -o -E '"@id": ?"[^"]*#org"' | sort | uniq -c- On our sites
Every Basalt product names Basalt Studio as
https://hellobasalt.dev/#org. Run on Upright, Kaç Papel and Outcrop’s /services/ on 21 Oct, the command printed:1 "@id": "https://hellobasalt.dev/#org" 3 "@id":"https://hellobasalt.dev/#org" 1 "@id":"https://hellobasalt.dev/#org" 2 "@id":"https://outcrop.hellobasalt.dev/#org"Outcrop’s pages also carry their own node,
https://outcrop.hellobasalt.dev/#org, typedProfessionalServicebecause Outcrop sells a service, with Basalt Studio as itsparentOrganization. That type comes back in step 5.
Add a BreadcrumbList to every page below the home page
- Do
One
BreadcrumbListwith aListItemfor each level, from the home page down to this one, matching the breadcrumb links on the page. It tells a search engine where the page sits in the site.- Check
A page one level below the home page should print one BreadcrumbList and two ListItems.
curl -s "$page" | grep -o -E '"@type": ?"(BreadcrumbList|ListItem)"' | sort | uniq -c- On our sites
Kaç Papel’s /fiyat price page has had one since 19 Oct (kacpapel PR #12). Every Outcrop page below the home page that is in the sitemap has one, built from the same list as the visible breadcrumb. Run on kacpapel.app/fiyat, then on Outcrop’s /services/, the command printed:
1 "@type":"BreadcrumbList" 2 "@type":"ListItem" 1 "@type":"BreadcrumbList" 2 "@type":"ListItem"
Use FAQPage only where the page shows the questions
- Do
Mark up questions and answers only when the page shows them as visible text, word for word. Google stopped showing the FAQ rich result in Search on 7 May 2026 (Search Central changelog), so the markup earns no extra space there. We keep it on pages that already have a FAQ, and we would not add a FAQ to a page for the markup.
- Check
This prints each question in the page’s FAQPage block with the number of times it appears in the visible text. Every line should start with 1 or more. A 0 means the markup describes something the reader cannot see.
curl -s "$page" > page.html perl -MJSON::PP -0777 -ne 'for (/<script[^>]*ld\+json[^>]*>(.*?)<\/script>/gs) { my $d = JSON::PP->new->decode($_); for my $n (@{ $d->{"\@graph"} || [$d] }) { print "$_->{name}\n" for @{ $n->{mainEntity} || [] } } }' page.html | while IFS= read -r q; do printf '%s %s\n' "$(perl -0777 -pe 's/<(script|style)\b.*?<\/\1>//gis; s/<[^>]+>/ /g' page.html | grep -c -F "$q")" "$q" done- On our sites
Upright’s home page has a FAQPage with 8 questions. On 21 Oct all 8 were in the visible text:
1 Is Upright free? 1 Which mice work? 1 macOS won't open Upright the first time. What do I do? 1 Do I need to remove Logi Options+? 1 Does it change my mouse permanently? 1 What permissions does it ask for? 1 Can I build it myself? 1 How do I uninstall it?
Add a Dataset to pages that publish data, with an Organization as creator
- Do
A page that publishes numbers others can reuse, such as a study or a stats page, can describe them as a
Dataset: name, description, licence, dates and adistributionpointing at the file. Google’s Dataset documentation listscreatoras a Person or an Organization. A more specific type, such asProfessionalService, does not pass.- Check
This prints the type of each Dataset’s creator and publisher. Both should print Organization or Person. “no @type” means the field only points at an
@id, and its type is whatever that node is.curl -s "$page" | perl -MJSON::PP -0777 -ne 'for (/<script[^>]*ld\+json[^>]*>(.*?)<\/script>/gs) { my $d = JSON::PP->new->decode($_); for my $n (@{ $d->{"\@graph"} || [$d] }) { next unless $n->{"\@type"} eq "Dataset"; print "$_: ", $n->{$_}{"\@type"} // "no \@type", "\n" for "creator", "publisher" } }'- On our sites
Our mistake. Outcrop’s two studies first pointed
creatorandpublisherat the site’s own node, which is aProfessionalService:"creator": { "@id": "https://outcrop.hellobasalt.dev/#org" }, "publisher": { "@id": "https://outcrop.hellobasalt.dev/#org" }Search Console flagged both fields as an invalid object type. On 21 Oct (outcrop PR #1, commit d35344a) the creator became Outcrop and the publisher Basalt Studio, each typed
Organization. The live study page now has:"creator": { "@type": "Organization", "name": "Outcrop", "url": "https://outcrop.hellobasalt.dev/", "parentOrganization": { "@id": "https://hellobasalt.dev/#org" } }, "publisher": { "@type": "Organization", "@id": "https://hellobasalt.dev/#org", "name": "Basalt Studio", "url": "https://hellobasalt.dev/" }Run on the study page, Göçmeden’s numbers page and KahveDrop’s data page on 21 Oct, the check printed Organization for all six fields:
creator: Organization publisher: Organization creator: Organization publisher: Organization creator: Organization publisher: Organization
Make the build fail on a mistake you have made once
- Do
Google’s Rich Results Test shows what Google reads from one page, and the Schema Markup Validator checks any schema.org type. Neither runs by itself. A check in your build runs on every page, every time: parse every JSON-LD block, and fail on each mistake you have already made once.
- Check
Outcrop’s build runs
test/check.mjsafter rendering the site. It parses every JSON-LD block on every page, and since 21 Oct it also does this:// Google's Dataset rich result rejects any creator or publisher that is not an Organization or Person. for (const node of ld['@graph'] || [ld]) { if (node['@type'] !== 'Dataset') continue; for (const f of ['creator', 'publisher']) { if (!['Organization', 'Person'].includes(node[f]?.['@type'])) err(`${rel}: Dataset ${f} is not an Organization`); } }- On our sites
To see it work, we pointed the study’s creator back at the site’s own node in a copy of the repository on 21 Oct. The build stopped with:
Built 28 pages for https://outcrop.hellobasalt.dev into dist/ ✗ /research/ai-readiness-2026-10/index.html: Dataset creator is not an Organization 1 problem(s).