SEO for a single-page app, without pretending to have a…
AI crawlers do not run JavaScript. Neither, reliably, does anything else you care about. Here is how I get real content into static HTML without adopting…
Here is the uncomfortable fact behind every "my React app will not rank" question: your page is an empty <div> with a script tag. Googlebot will usually render it eventually. GPTBot, ClaudeBot and PerplexityBot will not render it at all.
That second group matters more every month, and it is the one people design for last.
Diagnose the funnel before touching anything
Almost every "SEO problem" I get asked about turns out to be a different problem than the one being described. Submitting a sitemap is not ranking. The funnel has four stages and they fail differently:
- Indexed — is the URL in the index at all?
- Crawlable — can a bot reach it? Robots, redirects, canonicals, a 404 that returns 200.
- Renderable — does the fetched HTML contain the content, or only the shell?
- Ranking — is the content good enough and distinct enough to place?
Work upward from the bottom. Improving content on a page nothing can crawl is effort spent on a stage that is not the bottleneck. The single most common real answer is stage three, and the single most common wrong fix is more keywords.
Prerendering is the whole trick
The fix is not server rendering. Server rendering means a server, which means a bill, which is the thing I have designed everything else to avoid. The fix is to run the app once at build time and write the resulting HTML to disk.
You need a list of routes, a headless browser or a DOM implementation, and a post-build step. For every route: render, snapshot the HTML, write it to dist/<route>/index.html. Your host serves the static file; the app hydrates over it for humans; a crawler that never runs a line of JavaScript still reads the whole page.
The parts people get wrong:
- Data that is not there at build time. A route rendered against an empty database produces a snapshot of a loading skeleton, which is worse than no snapshot — it is a page confidently full of nothing. Either the build reads the real data, or that route does not get prerendered.
- Per-route metadata. One shared title across four hundred URLs is the fastest route to "Duplicate, Google chose a different canonical". Title and description must be per route and genuinely different.
- The canonical tag. Emitted per page, absolute, pointing at itself. A canonical pointing at the homepage on every page is a common copy-paste that deindexes an entire site.
The content floor is real and it is higher than you think
This is where most programmatically generated sites die. Below roughly six hundred words, a page is treated as thin regardless of how well it is structured. For a set of pages that share a template — tools, locations, categories — the target is more like a thousand to twelve hundred genuinely unique words each.
"Unique" is doing the work in that sentence. A template with three substituted words is one page as far as an index is concerned, and a few hundred of them get crawled once and dropped together.
What actually earns the placement is structure that answers questions:
- The opening sentence of every section answers that section's heading. Not builds toward it — answers it.
- A subheading roughly every two to three hundred words.
- Lists and comparison tables, because those are the elements that get extracted into an answer.
- One
h1, no skipped levels. An answer engine uses the outline to decide what the page is about.
Write for extraction, not for a keyword
The shift that changed my results was writing the first sentence of each section as a standalone answer. An engine lifting one sentence out of your page should produce something that makes sense with no surrounding context.
Definition first, then detail. If the opening line of a section only makes sense after the paragraph before it, it will never be quoted.
The corollary is that padding actively hurts. An introduction that spends eighty words clearing its throat before defining the subject has buried the one sentence that had a chance of being extracted.
The machine-readable layer
Three files, cheap to generate, and each one does a job:
sitemap.xml with a real lastmod re-emitted on every build. A lastmod that never changes is ignored; one that changes on every build regardless of content is worse, because it trains a crawler to distrust yours.
robots.txt that names the sitemap and does not accidentally block the AI crawlers you want reading you.
llms.txt — a plain description of what the product is and what its pages cover. Cheap, and it is read.
Add JSON-LD per page: Article for a post, Service for a service page, BreadcrumbList everywhere. It is derived from the same record the page renders, never hand-written beside it, or the two disagree the first time someone edits a heading.
Internal linking is the part people skip entirely
A page nobody links to is a page nothing finds, and this failure is invisible to every tool people normally run. A link checker crawls what exists and reports broken links; it cannot report a page with no inbound links at all, because there is no href to check.
The floor I hold to is two to three internal links on any page, and six to eight on a programmatic one, arranged as a hub and spokes rather than as a chain. A category page links to its members and each member links back and sideways to two or three siblings. That structure is also what makes a crawl budget go further, because a crawler arriving on any page can reach the rest of the set.
Because it is invisible to ordinary tooling, I made it a test. The build inverts the link graph and fails on any route with zero inbound links, printing the denominator so the result cannot quietly become "zero orphans of zero routes". It has caught real pages more than once — most memorably an account page that existed, worked, was in the sitemap, and had no link to it from anywhere in the application.
The other half of the same discipline is the sitemap being generated from the same route list the application uses, rather than maintained beside it. A hand-written sitemap is a second source of truth, and it is stale the first time somebody adds a page and does not think about it — which is every time.
The two things I will not do
I will not fabricate a statistic to make a page look authoritative. A number nobody can check is a liability that outlives the person who wrote it, and one falsified claim discredits the twenty around it.
And I will not noindex a thin page to hide it. If the page is not worth indexing, it is not worth having. Hiding it does not fix the reason it is thin — it just moves the problem somewhere nobody looks.
https://aoneahsan.com/blog/seo-pwa-without-ssr-hacks