I searched Google for the name of my own product and the first result from my own site was the refund policy.
Not the front page. The page that explains how to get your money back.
My first instinct was that this was some ranking mystery, the kind of thing you read forum threads about for two hours. It was not a mystery. It took about four minutes to find and it was entirely my fault.
The front page is a React single-page app. Which means the HTML that leaves my server is this, roughly:
<div id="root"></div>
Everything a visitor reads — the headline, what the app does, the price, the FAQ, all of it — is assembled by JavaScript after the page arrives. The document itself contains none of it.
I counted the words in what I was actually serving. Forty-six. And I am being generous, because roughly half of those were a comment left in the source and a bit of boilerplate. The real content was closer to twenty words.
The refunds page, meanwhile, is a plain static HTML file. Someone wrote sentences, the sentences are in the file, the file is what gets served. About 165 words of ordinary prose.
Given a page with twenty words and a page with a hundred and sixty-five, Google picked the one with a hundred and sixty-five. That is not a bug in the ranking. That is the ranking working exactly as described.
It does. This is the response I expect and it is true, and it is also the reason I did not catch this for months.
Google renders JavaScript in a second pass, separately from the initial crawl and not on the same schedule. So the content exists eventually. But "eventually" competes against a static file that was complete on arrival, and for a domain a few weeks old with essentially no authority, I do not think I get the benefit of the doubt in that queue.
The second thing is that Google is not the only reader anymore, and increasingly not the most important one. Anything summarising a link — a chat assistant, a preview card in a messaging app, an aggregator building a directory entry, a journalist's tooling — is very often reading the raw document. None of those run my bundle. All of them were getting the empty div.
Which reframes the problem. It is not that I was losing a ranking position. It is that for a large and growing class of readers, my front page had nothing on it at all, and I had no way of noticing because every browser I own runs JavaScript.
The obvious industrial answer is to move to a framework with server-side rendering. That is a rewrite, and a rewrite of the one page that currently converts visitors into sales is a bad trade for a business this size.
So instead: a small script that runs at the end of the build and writes the actual copy into the container.
tsc -b && vite build && node tools/prerender-portada.mjs
It imports the same copy module the app itself imports, renders it to plain semantic HTML, and injects it inside <div id="root">. When the bundle loads, createRoot().render() replaces the children of that container, so a visitor with JavaScript sees precisely what they saw before. A reader without JavaScript sees the page.
Two details that took longer to settle than the script did.
The copy has exactly one source. The prerender imports the same module the components import. If I ever end up maintaining a second copy of my own marketing text, the two will drift, and the version search engines read will be the stale one. This is the part I would get wrong if I were in a hurry.
Nothing is hidden. The tempting move is display:none on the prerendered block so there is no flash before React mounts. I did not do it, for two reasons. It is text served to crawlers and not shown to people, which is the textbook definition of a thing Google penalises for. And more simply, it would be a lie about what the page contains.
What I did instead is ordering. The hero sits first at 100vh, everything else flows below it. During the fraction of a second before mount, what is on screen is a plain, quiet version of the hero. The rest is real, present, and below the fold. It is honest and it looks fine.
Serving went from 46 words to 1,865.
Whether it changed the ranking. It has not been long enough, the sample is one small site, and I would rather say that than publish a graph that implies a causal claim I cannot support.
What I can say is the part that does not depend on Google at all: paste my front page into anything that reads a URL without executing it, and it now describes the product instead of returning an empty container. That was true the moment the build ran.
If you run a single-page app for anything commercial, the check takes fifteen seconds:
curl -s https://yoursite.com | wc -w
If the number surprises you, it has probably been surprising you for a while.
I make Tidy, a Mac menu bar app that reads what is inside your files and names them from their contents, on my own in Santiago, Chile. $9 once, macOS 14 and up, and all of it runs on the machine — no account, no server, nothing uploaded. I answer every email myself.