Outcropby Basalt

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.

  1. Describe the product: SoftwareApplication or WebApplication, with an offer

    Do

    On the home page, one SoftwareApplication block for an app people install, or WebApplication for one that runs in the browser. Give it the name, a one-line description, applicationCategory, operatingSystem and an offers block with price and priceCurrency. 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"
  2. Name the maker once, and point every publisher at it

    Do

    Describe your company once, as an Organization with an @id, a name, a URL and sameAs links to its profiles. Point every product’s publisher at that @id. The same @id on every site tells a search engine that the products have one maker.

    Check

    This counts the @ids ending in #org on 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, typed ProfessionalService because Outcrop sells a service, with Basalt Studio as its parentOrganization. That type comes back in step 5.

  3. Add a BreadcrumbList to every page below the home page

    Do

    One BreadcrumbList with a ListItem for 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"
  4. 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?
  5. 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 a distribution pointing at the file. Google’s Dataset documentation lists creator as a Person or an Organization. A more specific type, such as ProfessionalService, 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 creator and publisher at the site’s own node, which is a ProfessionalService:

    "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
  6. 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.mjs after 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).