# SEO and structured data for PWAs

> How JSON-LD structured data and crawlable HTML help search engines understand a PWA and qualify it for rich results, checked with the Rich Results Test.

At the end of this guide every public page of the PWA carries a `WebApplication` JSON-LD
block that Google's Rich Results Test parses without errors, the block is generated from the
same data as the visible page, and a build-time check fails when the markup stops parsing.
A PWA distributed as a website is indexed like any other site; MDN's installability guide
puts it as "making it discoverable through web search". The manifest and service worker add
nothing to that indexing, so this guide is about the HTML the crawler sees.

You need pages that render their primary content as HTML (server-rendered or prerendered),
a public URL per page, and access to the `<head>` template.

## Choose the schema.org type and properties

schema.org defines `WebApplication` under `Thing > CreativeWork > SoftwareApplication >
WebApplication`. Its own property is `browserRequirements`, described as "Specifies browser
requirements in human-readable text. For example, 'requires HTML5 support'". The useful
properties come from `SoftwareApplication`: `applicationCategory`, `operatingSystem`,
`screenshot`, `softwareVersion`, `featureList`, and `offers` (inherited from `CreativeWork`)
for a free or paid app. Use the values your manifest already holds so that `name`,
`description`, and the screenshot URLs cannot drift from what the install UI shows.

## Emit the JSON-LD from the manifest data

Google Search Central recommends JSON-LD because it is "less prone to user errors" than
Microdata or RDFa and accepts it "in a `<script>` tag in the `<head>` and `<body>` elements".
In the page template, serialise one object per page:

```html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "WebApplication",
  "name": "Field Notes",
  "url": "https://notes.example/",
  "description": "Offline-first notes that sync when you are back online.",
  "applicationCategory": "ProductivityApplication",
  "operatingSystem": "Any",
  "browserRequirements": "Requires JavaScript and a browser with service worker support.",
  "screenshot": "https://notes.example/screenshots/desktop-wide.png",
  "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
}
</script>
```

Google's general guideline applies to every property: "don't add structured data about
information that is not visible to the user, even if the information is accurate", and
"Don't create blank or empty pages just to hold structured data". A `screenshot` that the
page does not display, or a `description` that differs from the visible one, is a guideline
violation even when the facts are right.

## Keep the content crawlable without the service worker

The crawler does not run your service worker, so a page whose content only appears after a
cached shell boots is indexed as the empty shell. Render the article, product, or note body
into the HTML response and let the service worker cache that same response. Google states
it "can read JSON-LD data when it is dynamically injected into the page's contents", so a
client-side injection is tolerated for the markup itself, but a static block in the response
removes the dependency on JavaScript execution during rendering.

## Validate with the Rich Results Test and a build check

Open the Rich Results Test, paste the public URL under **Enter a URL to test** or switch to
**Code** and paste the HTML, and run it. The result lists detected items and any parse or
required-property errors. Add a check to the build that parses each emitted block so a
template bug fails before deploy rather than in Search Console:

```js
import { readFile } from 'node:fs/promises';

export async function assertJsonLd(htmlPath) {
  const html = await readFile(htmlPath, 'utf8');
  const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
  if (blocks.length === 0) throw new Error(`${htmlPath}: no JSON-LD block`);
  for (const [, json] of blocks) {
    const data = JSON.parse(json); // throws on a broken template
    if (data['@type'] !== 'WebApplication') throw new Error(`${htmlPath}: unexpected @type ${data['@type']}`);
  }
}
```

`JSON.parse` catches malformed output; it does not check schema.org vocabulary, which is
what the Rich Results Test does, so run both.

:::observed
The Rich Results Test (search.google.com/test/rich-results, English UI, read 2026-10-03)
opens with the heading "Does your page support rich results?" and offers two inputs, **Enter
a URL to test** and **Code**, plus a device choice between "Google Inspection Tool smartphone"
and "Google Inspection Tool desktop". The URL mode fetches the live page as Googlebot would,
so it tests a staging URL only if that URL is reachable without a login.
:::

## See also

- [Make it installable](/guides/installable/)
- [`screenshots` manifest member](/reference/manifest/screenshots/)
- [`description` manifest member](/reference/manifest/description/)
- [Introduction to structured data markup in Google Search](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data) (developers.google.com)
- [WebApplication](https://schema.org/WebApplication) (schema.org)