Notes

Why a static site beats a single-page app for anything that needs to rank

Published 2026-10-06 · 5 min read

If a page needs to be discovered through search, the key question is not which framework is fashionable. It is what the server sends before any JavaScript runs. This guide explains why that matters, shows how to check it yourself in about thirty seconds, and describes how one clock app ended up with an empty page twice, for two different reasons. Opinions are marked as opinions.

The page a person sees and the page a crawler reads

A person opens a site in a browser that runs JavaScript, so they see the completed page. A meaningful share of the tools that access a page do not do that. Some crawlers read the raw response and stop. Some automated summarizers do the same, as do some link-preview generators. Search engines that execute JavaScript may eventually see the content, but "eventually" is not something you want to rely on when the page needs to earn its place in results. If the raw response contains a title, a description and an empty container, then the page is blank to those tools.

Check it yourself with curl

You do not need special tools. curl fetches the raw response without running any scripts, which is exactly what a non-JavaScript reader receives:

curl -s https://your-site.example/ | head -n 60

# count the words in the raw HTML (crude, but revealing)
curl -s https://your-site.example/ | sed 's/<[^>]*>//g' | wc -w

Read the output. Can you see your headline, main paragraphs and links in the text? If you find only a script tag and something like <div id="root"></div>, then everything a crawler could index is created later in the browser. The browser's "view source" command shows the same raw response, while the developer tools element inspector shows the page after scripts have run. That is why the inspector can make a broken page look fine.

Failure one: a canvas instead of a document

The first version of the web app for a Bitcoin clock called BlockClock was built in Flutter, reusing the codebase planned for a future mobile app. It worked perfectly in a browser: the numbers updated every few seconds and the split-flap animation flipped on every digit. The problem was invisible until I checked what the server actually sent. Flutter on the web paints the whole interface onto a single canvas instead of building ordinary HTML elements. The document it ships is nearly empty: a canvas tag, a loading script, and nothing else. No headings, numbers or words for anything to read.

The fix was to rebuild the web app in React with Vite, using ordinary DOM elements for the interface. The Flutter codebase was kept for a later mobile app, where canvas rendering is a reasonable trade-off because a native app does not need a crawler to read its interior. Opinion: web and mobile can reasonably want different architectures, and two codebases kept on purpose beat one codebase that is bad at one of the jobs.

Failure two: React has the same problem by default

Switching frameworks fixed the canvas problem but uncovered a second, similar issue. A default React single-page app sends a nearly empty root element in its raw HTML, then fills it with JavaScript once the browser runs it. For a while, BlockClock's first response contained a title tag, a meta description and an empty div, with no visible text anywhere in it.

The fix was simpler than the framework choice had been. The site was split into a content page and an app. The root of the site became an ordinary static page that explains, in full sentences, what the clock shows before any JavaScript runs. The live board moved to a separate address, clearly marked so it is not indexed on its own. A crawler landing on the homepage now reads real paragraphs immediately, while a person who wants the clock clicks through to it. Separating "the page that explains" from "the page that runs" mattered more than which JavaScript framework was doing the running.

What a static page looks like when you do it from the start

FitCalc, a set of fitness calculators, never had to learn this lesson the hard way because it was static HTML from the first line. It is generated by a small Node build script with no framework and no runtime dependency: the script reads content files and writes plain HTML. There is no hydration step and no framework deciding what a crawler sees first. A calculator page ships as a finished document, and its JavaScript only runs the arithmetic once you fill in the form, not the page itself. Opinion: for a site whose entire value depends on being read by search engines, giving up a framework's conveniences is a fair price for never having a rendering problem to debug.

The second clock, AltClock, reused BlockClock's React setup along with the lesson about static content pages, and it links from inside the app to plain-language explainer pages for its two key ideas, distance from an all-time high and the break-even multiple.

When a single-page app is still the right tool

None of this means single-page apps are bad. A live dashboard, an editor or anything behind a login has no reason to be crawled and benefits from client-side rendering. The mistake is using one tool for two jobs. If a project has both a page that should rank and an interactive app, split them: a static document that explains the product, and a separate address for the interactive part. Other approaches exist, such as rendering on the server or generating pages ahead of time inside a framework, and I have not tested them here, so I make no claim about them. The split described above is simply the one that worked for these apps.

A pre-launch checklist

  • Run curl on your home page and on one deep page. Do the headline, main paragraphs and navigation links appear as text?
  • Compare "view source" with the element inspector. A large difference means the content is created in the browser.
  • Make sure every important page is reachable through a real link element, not only through click handlers, so a crawler can discover it.
  • Give each page its own title, description and canonical address in the raw HTML.
  • Keep the interactive app and the explanatory content on separate addresses, and mark the app as not-for-indexing if it has no standalone content.
  • Repeat the check after every deploy. Frameworks and build settings change, and an empty page does not announce itself.

Summary

The page a visitor sees is not necessarily the page a crawler reads, and the only way to know the difference is to inspect the raw response. Both the canvas approach and the default React approach produced an empty document for different reasons, and the cure in both cases was the same: put the words in the HTML the server sends, and keep the interactive parts somewhere else. A thirty-second curl check would have caught the problem before launch.