<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Oleg Kolesnykov</title>
    <description>The latest articles on DEV Community by Oleg Kolesnykov (@ok1mind).</description>
    <link>https://dev.to/ok1mind</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4127993%2F758e41a7-8aa8-460c-92f8-fc38294ed0e2.png</url>
      <title>DEV Community: Oleg Kolesnykov</title>
      <link>https://dev.to/ok1mind</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ok1mind"/>
    <language>en</language>
    <item>
      <title>I prerendered my React SPA for SEO. I forgot the links.</title>
      <dc:creator>Oleg Kolesnykov</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:41:43 +0000</pubDate>
      <link>https://dev.to/ok1mind/i-prerendered-my-react-spa-for-seo-i-forgot-the-links-2d3l</link>
      <guid>https://dev.to/ok1mind/i-prerendered-my-react-spa-for-seo-i-forgot-the-links-2d3l</guid>
      <description>&lt;p&gt;My site had been live for a month. Google had indexed all 352 pages. Titles matched&lt;br&gt;
search queries exactly. Structured data was in place. hreflang covered four locales.&lt;/p&gt;

&lt;p&gt;Then I ran a site audit and got 24 errors, all the same:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Page has no outgoing links&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The homepage. Every locale homepage. Every tool page. And — the one that actually&lt;br&gt;
hurt — the hub page that is supposed to link to all 78 tarot cards.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://tarot-magic.com/uk/card-meanings | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;a [^&amp;gt;]*href='&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Zero. Then in the browser console, on the same page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
&lt;span class="c1"&gt;// 56&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty-six links that existed for humans and did not exist for crawlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had actually built
&lt;/h2&gt;

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

&lt;p&gt;Here is what the prerendered body looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;noscript&amp;gt;&lt;/span&gt;You need to enable JavaScript to run this app.&lt;span class="nt"&gt;&amp;lt;/noscript&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pre-bg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pre-wrap"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Tarot Card Meanings&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pre-lead"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;A complete encyclopedia of all 78 cards&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Frequently asked questions&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;How many cards are in a tarot deck?&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;A standard deck has 78 cards: 22 Major Arcana and 56 Minor Arcana…&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
      ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Text, headings, FAQ copy. All of it useful. And not a single &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had solved "crawlers can't read my content" and never noticed I hadn't solved&lt;br&gt;
"crawlers can't navigate my site."&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that made it obvious
&lt;/h2&gt;

&lt;p&gt;The same site has a blog. Articles are generated by a different code path, and&lt;br&gt;
those pages looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pre-bg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pre-wrap"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pre-article"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/uk/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/uk/blog"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Journal&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#the-element-of-fire"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;The Element of Fire&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;So this was never an architecture problem. The machinery was already there. One&lt;br&gt;
template had links; the other twelve didn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it mattered more than I expected
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;Bing and Yandex barely render JS at all.&lt;/strong&gt; Over the same period:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Search engine&lt;/th&gt;
&lt;th&gt;Impressions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;10,820&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bing&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yandex&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;My average position across all queries was 23.9 — page three. Internal linking is&lt;br&gt;
one of the two levers that move that number. I had turned it off without knowing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;No architecture change. Put links into the prerendered HTML:&lt;/p&gt;

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

&lt;p&gt;I re-ran the audit a few hours after deploying:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Health score&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Only one incoming internal link"&lt;/td&gt;
&lt;td&gt;36 pages&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Internal links discovered&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,020&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10,056&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow pages&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Ranking effects are still pending. Google has to recrawl 312 pages and rebuild the&lt;br&gt;
graph; that takes weeks, not hours. I'll know in a month whether it moved anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  The check that would have saved me a month
&lt;/h2&gt;

&lt;p&gt;If you prerender, statically generate, or SSR a JS app, run this before you trust it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;path &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="s2"&gt;"about"&lt;/span&gt; &lt;span class="s2"&gt;"products"&lt;/span&gt; &lt;span class="s2"&gt;"blog"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-20s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"/&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://yoursite.com/&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;a [^&amp;gt;]*href='&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;I checked my word counts religiously. I never checked my link counts. Word count&lt;br&gt;
tells you a crawler can &lt;em&gt;read&lt;/em&gt; the page. Link count tells you it can &lt;em&gt;leave&lt;/em&gt; it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The site in question is &lt;a href="https://tarot-magic.com" rel="noopener noreferrer"&gt;tarot-magic.com&lt;/a&gt; — a tarot&lt;br&gt;
reference in four languages. If you want to see the check pass, run the command&lt;br&gt;
above against &lt;code&gt;/uk/card-meanings&lt;/code&gt;. It returns 105 now.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>seo</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
