DEV Community

Cover image for I prerendered my React SPA for SEO. I forgot the links.
Oleg Kolesnykov
Oleg Kolesnykov

Posted on

I prerendered my React SPA for SEO. I forgot the links.

My site had been live for a month. Google had indexed all 352 pages. Titles matched
search queries exactly. Structured data was in place. hreflang covered four locales.

Then I ran a site audit and got 24 errors, all the same:

Page has no outgoing links

The homepage. Every locale homepage. Every tool page. And — the one that actually
hurt — the hub page that is supposed to link to all 78 tarot cards.

That last one made no sense. I could open it in my browser and see 78 links right
there. So I did what I should have done much earlier:

curl -s https://tarot-magic.com/uk/card-meanings | grep -o '<a [^>]*href=' | wc -l
# 0
Enter fullscreen mode Exit fullscreen mode

(Note the grep -o ... | wc -l — not grep -c. Most HTML ships minified on a
single line, so grep -c counts lines containing a match and cheerfully
reports 1 no matter how many links you have. I got that wrong the first time
and briefly convinced myself the fix hadn't worked.)

Zero. Then in the browser console, on the same page:

document.querySelectorAll('a[href]').length
// 56
Enter fullscreen mode Exit fullscreen mode

Fifty-six links that existed for humans and did not exist for crawlers.

What I had actually built

The site is a React SPA. At some point I added a prerender step so crawlers would
get real content instead of an empty <div id="root">. It worked — page text was
in the HTML, word counts were healthy, pages ranked.

Here is what the prerendered body looked like:

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root">
    <div class="pre-bg"><div class="pre-wrap">
      <h1>Tarot Card Meanings</h1>
      <p class="pre-lead">A complete encyclopedia of all 78 cards</p>
      <h2>Frequently asked questions</h2>
      <h3>How many cards are in a tarot deck?</h3>
      <p>A standard deck has 78 cards: 22 Major Arcana and 56 Minor Arcana…</p>
      ...
Enter fullscreen mode Exit fullscreen mode

Text, headings, FAQ copy. All of it useful. And not a single <a href>.

I had solved "crawlers can't read my content" and never noticed I hadn't solved
"crawlers can't navigate my site."

The part that made it obvious

The same site has a blog. Articles are generated by a different code path, and
those pages looked like this:

<div class="pre-bg"><div class="pre-wrap"><div class="pre-article">
  <a href="/uk/">Home</a>
  <a href="/uk/blog">Journal</a>
  <a href="#the-element-of-fire">The Element of Fire</a>
  ...
Enter fullscreen mode Exit fullscreen mode

26 links, 20 of them internal. Same wrapper, same mechanism — someone (me, months
earlier) had put links into the article template and not into anything else.

So this was never an architecture problem. The machinery was already there. One
template had links; the other twelve didn't.

Why it mattered more than I expected

Google renders JavaScript, so my links weren't invisible to it. But rendering
is a second, deferred pass. Google crawls the raw HTML first, builds a link graph
from it, and comes back to render later. For a month, the link graph Google built
from my site was: 352 pages, no edges.

Bing and Yandex barely render JS at all. Over the same period:

Search engine Impressions
Google 10,820
Bing 16
Yandex 11

I had been telling myself that was a new-domain problem. Some of it was. But those
crawlers were also looking at a site where nothing linked to anything, discovering
pages only through sitemap.xml, and reasonably concluding there was no structure
worth indexing.

My average position across all queries was 23.9 — page three. Internal linking is
one of the two levers that move that number. I had turned it off without knowing.

The fix

No architecture change. Put links into the prerendered HTML:

  1. Header and footer nav on every template — the global one, five minutes of real work, fixes every page at once.
  2. The hub page lists its 78 cards as actual anchors — this is the page whose entire job is distributing link equity to 312 card pages across four locales.
  3. Card pages link to their siblings — previously they had 3 links, all breadcrumbs.

Results, same day

I re-ran the audit a few hours after deploying:

Before After
Health score 95 100
Errors 24 0
"Only one incoming internal link" 36 pages 4
Internal links discovered 1,020 10,056
Slow pages 11 1

That last row was a surprise — I hadn't touched performance. My guess is the crawler
was spending its budget differently once it could actually traverse the site, but
I can't prove that, so take it as a coincidence until someone shows me otherwise.

Ranking effects are still pending. Google has to recrawl 312 pages and rebuild the
graph; that takes weeks, not hours. I'll know in a month whether it moved anything.

The check that would have saved me a month

If you prerender, statically generate, or SSR a JS app, run this before you trust it:

for path in "" "about" "products" "blog"; do
  printf '%-20s %s\n' "/$path" \
    "$(curl -s "https://yoursite.com/$path" | grep -o '<a [^>]*href=' | wc -l)"
done
Enter fullscreen mode Exit fullscreen mode

Compare each number to document.querySelectorAll('a[href]').length in the browser
on the same URL. If the browser number is much bigger, your navigation is invisible
to anything that doesn't run JavaScript.

I checked my word counts religiously. I never checked my link counts. Word count
tells you a crawler can read the page. Link count tells you it can leave it.


The site in question is tarot-magic.com — a tarot
reference in four languages. If you want to see the check pass, run the command
above against /uk/card-meanings. It returns 105 now.

Top comments (0)