You shipped a Progressive Web App. It loads fast, works offline, and feels native. Then you check Google Search Console and find that your pages either aren't indexed or rank for nothing. The culprit is almost always the same: an indexable PWA needs its main content present in the rendered HTML, and a client-only app hides that content behind JavaScript that crawlers may never execute. This guide walks through the concrete decisions that make PWA SEO work, with code you can copy.
Why client-side rendering breaks indexing
Googlebot crawls in two waves. First it fetches the raw HTML. Later, when rendering resources are available, it runs your JavaScript in a headless Chromium and indexes the rendered DOM. The second wave is not guaranteed to happen quickly, and other crawlers (Bing in many cases, and most LLM/AI bots) do not render JavaScript at all.
If your initial HTML is an empty <div id="root"></div> plus a bundle, then a non-rendering crawler sees nothing. Even Googlebot can get a thin or empty snapshot if your app throws, times out, or depends on a runtime API call that fails in the render environment. The first rule of an indexable PWA: the main content and the internal links must exist in HTML that does not require executing your application to read.
SSR, prerendering, and dynamic rendering trade-offs
There are three mainstream ways to put real content in the HTML. Pick based on how dynamic your content is and what infrastructure you can run.
| Approach | How it works | Best for | Cost / risk |
|---|---|---|---|
| Server-side rendering (SSR) | Server renders HTML per request, client hydrates | Frequently changing content, logged-in/personalized views | Server compute; hydration mismatches |
| Static prerendering (SSG / build-time) | Pages rendered to HTML at build time, served as files | Mostly static content (docs, marketing, blogs) | Stale until rebuild; not for per-user data |
| Dynamic rendering | Detect bots, serve a prerendered snapshot only to them | Legacy SPAs you cannot easily convert | Two code paths to keep in sync; Google calls it a workaround, not a long-term fix |
SSR (or hydration frameworks like Next.js, Nuxt, SvelteKit, Astro) is the most robust path for a modern indexable PWA because crawlers and users get the same HTML. Static prerendering is ideal when content does not change per request. Dynamic rendering is a transitional crutch: you maintain two render paths and risk cloaking accusations if the bot snapshot diverges from what users see. Serve bots the same content, never a different one.
App shell vs content
The app-shell pattern caches the UI chrome (header, nav skeleton, footer) so repeat visits feel instant. That is great for users, but the shell is not your indexable content. Do not let the shell become the entire first-paint HTML while the article, product, or listing arrives only after a client fetch.
Keep the distinction explicit: the shell is for perceived performance and offline; the content for each route must be in the server-rendered HTML for that URL. A common failure is a service worker that returns the cached shell for every navigation, producing identical thin pages across the site.
Use real links, not onclick routing
Crawlers discover pages by following <a href> attributes in the HTML. They do not click buttons or fire synthetic events. If your router navigates via onClick handlers on <div> or <span> elements, those destinations are invisible to discovery.
<!-- Invisible to crawlers: no href to follow -->
<div onclick="router.push('/blog/post')">Read post</div>
<!-- Crawlable: real anchor with a resolvable URL -->
<a href="/blog/post">Read post</a>
Client-side routers can still intercept the click for a soft navigation, but the underlying href must be a real, server-resolvable URL. In React Router, Next.js, or Vue Router this is the default when you use <Link> components correctly. Audit your nav, pagination, and faceted filters specifically, those are where teams quietly drop hrefs.
Canonical and robots: get the head right
Every indexable route needs a self-referencing canonical and must not be accidentally blocked. Two mistakes dominate: leaving a sitewide noindex from a staging build, and disallowing your JS/CSS in robots.txt so Googlebot cannot render the page at all.
<link rel="canonical" href="https://example.com/blog/post/" />
<meta name="robots" content="index,follow" />
Render these tags server-side. If you only set them client-side via JavaScript, a non-rendering crawler reads stale or missing directives. Do not block /_next/, /static/, or your bundle paths in robots.txt, blocked resources are the single most common reason a PWA renders blank for Google.
Handle soft 404s on client routes
SPAs frequently return HTTP 200 for URLs that don't exist, then show a "Not found" message in the client. Google flags these as soft 404s and may drop valid pages too. For unknown routes, return a real 404 (or 410) status from the server. With SSR you can branch on the route; with static hosting, configure a true 404 response rather than rewriting everything to index.html with a 200.
Lazy-loaded content and infinite scroll
Content that only appears after a scroll event, an intersection-observer trigger, or a "load more" click is at risk. Googlebot has a tall but finite viewport and does not scroll like a user. Two safe patterns:
- Render the primary content in the initial HTML; lazy-load only below-the-fold enhancements or images (use native
loading="lazy"on<img>, which is crawler-safe). - For paginated lists, expose real paginated URLs (
/products?page=2) with crawlable<a href>links instead of relying solely on infinite scroll.
Verify what Google actually sees
Never trust "it works in my browser." Use the URL Inspection tool in Google Search Console: run Test Live URL, then open View Tested Page → HTML and confirm your headline, body copy, internal links, and canonical are present in the rendered HTML. If the rendered HTML is empty or thin, your indexable PWA isn't indexable yet. Cross-check with a quick curl to see the raw, non-rendered response that non-JS crawlers receive:
curl -sL https://example.com/blog/post/ | grep -i "<h1\|canonical\|noindex"
Practical checklist
- Main content for each URL is in server-rendered HTML (SSR or prerender), not fetched client-side only.
- Internal navigation uses real
<a href>values, notonClickon non-anchor elements. - Self-referencing
rel="canonical"and correctrobotsmeta are rendered server-side per route. robots.txtdoes not block JS, CSS, or bundle paths.- Unknown client routes return a real
404/410, not a 200 soft 404. - Lazy-loaded primary content is moved into initial HTML; only images and below-fold extras are deferred.
- The service worker does not serve one cached shell for every URL.
- You verified the rendered HTML in URL Inspection and the raw HTML with
curl.
Get these eight right and your PWA SEO problem largely disappears: crawlers see the same content your users do, links are followable, and signals are clean. The rest is normal content and authority work.
The same "make it visible to machines" principle extends beyond HTML. If you're building automation around programmatic media, our JSON to video developer guide covers structuring renderable output the same disciplined way, and our roundup of the best video APIs for developers in 2026 compares the tooling. For an overview of the platform, start at the SamAutomation homepage.
Build your first automated video
One API key for deterministic JSON-to-video plus AI video & image generation. Documented and ready for your pipeline.