Internal linking gaps are silently killing your React app's SEO

Internal linking gaps are silently killing your React app's SEO

# seo# react# webdev
Internal linking gaps are silently killing your React app's SEOMitu Das

I built a React SPA, deployed it, submitted the sitemap, and waited. Three weeks later, Google Search...

I built a React SPA, deployed it, submitted the sitemap, and waited. Three weeks later, Google Search Console was indexing maybe 30% of my pages. I assumed it was a crawl budget problem or a slow server. It wasn't.

It was internal links, or the complete lack of them.

My app had dozens of pages that were only reachable through JavaScript-driven navigation. No <a href> tags. No static links Google could follow. The pages existed, but from Google's perspective, they were islands.

This article is going to show you exactly how I found those orphaned pages, how I fixed the linking structure, and how you can audit your own app programmatically so you don't lose three weeks the same way I did.

Why React Apps Are Especially Bad at Internal Linking

This is the React SEO mistake I see most often, and it's subtle. When you use useNavigate() or history.push() to move between pages, you're doing JavaScript navigation. It works perfectly for users. For Googlebot, it's a wall.

// ❌ This looks fine to users. Googlebot can't follow it.
const GoToProduct = ({ id }) => {
  const navigate = useNavigate();
  return (
    <button onClick={() => navigate(`/product/${id}`)}>
      View Product
    </button>
  );
};

// ✅ This is crawlable. Same user experience.
const GoToProduct = ({ id }) => {
  return (
    <Link to={`/product/${id}`}>
      View Product
    </Link>
  );
};
Enter fullscreen mode Exit fullscreen mode

The difference is just four characters, Link vs a button with onClick. But the SEO difference is enormous.

React Router's <Link> component renders an actual <a href> tag in the DOM. Googlebot can see it, follow it, and build a graph of your site. A button with navigate() renders... a button. That's it. Dead end.

And the worst part? This pattern is everywhere in React codebases. Dashboard links, product cards, article teasers, "back" buttons, they all tend to get implemented as buttons or divs with click handlers because it feels more "React-y." It isn't wrong to the user. It's invisible to crawlers.

Step 1: Find Your Orphaned Pages Programmatically

Before you fix anything, you need to know the scope of the problem. Here's a script I use to crawl a built React app and find pages that have no inbound <a href> links from other pages.

// audit-links.mjs
// Run with: node audit-links.mjs https://yoursite.com

import * as cheerio from "cheerio";

const BASE_URL = process.argv[2];
const visited = new Set();
const inboundLinks = {}; // page -> Set of pages that link to it
const queue = [BASE_URL];

async function fetchPage(url) {
  try {
    const res = await fetch(url);
    const html = await res.text();
    return cheerio.load(html);
  } catch {
    return null;
  }
}

function normalizeUrl(href, fromUrl) {
  try {
    const url = new URL(href, fromUrl);
    if (url.origin !== new URL(BASE_URL).origin) return null;
    url.hash = "";
    url.search = "";
    return url.href;
  } catch {
    return null;
  }
}

async function crawl() {
  while (queue.length > 0) {
    const url = queue.shift();
    if (visited.has(url)) continue;
    visited.add(url);

    if (!inboundLinks[url]) inboundLinks[url] = new Set();

    const $ = await fetchPage(url);
    if (!$) continue;

    $("a[href]").each((_, el) => {
      const href = $(el).attr("href");
      const normalized = normalizeUrl(href, url);
      if (!normalized) return;

      if (!inboundLinks[normalized]) inboundLinks[normalized] = new Set();
      inboundLinks[normalized].add(url);

      if (!visited.has(normalized)) queue.push(normalized);
    });

    console.log(`Crawled: ${url} (${queue.length} remaining)`);
  }
}

async function main() {
  await crawl();

  const orphans = Object.entries(inboundLinks)
    .filter(([_, sources]) => sources.size === 0)
    .map(([url]) => url);

  console.log("\n=== ORPHANED PAGES (no inbound <a> links) ===");
  orphans.forEach((url) => console.log(` - ${url}`));
  console.log(`\nTotal pages found: ${visited.size}`);
  console.log(`Orphaned pages: ${orphans.length}`);
}

main();
Enter fullscreen mode Exit fullscreen mode

Install the one dep and run it:

npm install cheerio
node audit-links.mjs https://yoursite.com
Enter fullscreen mode Exit fullscreen mode

This gives you a concrete list of pages Google can't reach by following links. That's your fix list.

Step 2: Fix the Most Common Patterns

Once you have the list, the fixes usually fall into a few patterns. Here's what I found in my own app and how I addressed each one.

Pattern 1: Buttons pretending to be links

// Before
<div onClick={() => navigate(`/blog/${post.slug}`)}>
  <h2>{post.title}</h2>
</div>

// After — wraps the whole card in a real link
<Link to={`/blog/${post.slug}`} style={{ textDecoration: "none", color: "inherit" }}>
  <div>
    <h2>{post.title}</h2>
  </div>
</Link>
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Programmatic navigation in menus

// Before — a common pattern in sidebar navs
<MenuItem onClick={() => navigate("/settings/profile")}>Profile</MenuItem>

// After
<MenuItem component={Link} to="/settings/profile">Profile</MenuItem>
// Or if your component doesn't accept `component`:
<Link to="/settings/profile"><MenuItem>Profile</MenuItem></Link>
Enter fullscreen mode Exit fullscreen mode

Pattern 3: "View more" buttons in lists

These are especially bad because they're often the only entry point into detail pages.

// Before
<button onClick={() => navigate(`/product/${id}`)}>View Details</button>

// After — identical appearance, crawlable
<Link to={`/product/${id}`}>
  <button>View Details</button>
</Link>
Enter fullscreen mode Exit fullscreen mode

Note: wrapping a <button> in an <a> is technically invalid HTML, but it works in all browsers. If you care about strict validity, use <Link> with button styling instead:

<Link to={`/product/${id}`} className="btn btn-primary">
  View Details
</Link>
Enter fullscreen mode Exit fullscreen mode

Step 3: Automate This Check Going Forward

Finding and fixing the current gaps is a one-time effort. Making sure you don't reintroduce them is the ongoing challenge.

I ended up using a package called @power-seo to automate this audit in CI. It wraps the kind of crawl logic above and flags orphaned pages as part of a build check.

// seo-check.mjs
import { auditInternalLinks } from "@power-seo/audit";

const result = await auditInternalLinks({
  baseUrl: "http://localhost:3000",
  startPath: "/",
});

if (result.orphanedPages.length > 0) {
  console.error("SEO FAIL: Orphaned pages found:");
  result.orphanedPages.forEach((p) => console.error(` - ${p}`));
  process.exit(1); // Fails the build
}

console.log(`✓ All ${result.totalPages} pages are reachable via <a> links`);
Enter fullscreen mode Exit fullscreen mode

You add that to your package.json:

"scripts": {
  "seo:check": "node seo-check.mjs",
  "build": "vite build && npm run seo:check"
}
Enter fullscreen mode Exit fullscreen mode

Now the build fails if someone introduces an orphaned page. The problem can't sneak back in.

If you want to go deeper on how this audit works under the hood, including gap analysis between your sitemap and your crawled link graph — there's a solid walkthrough at ccbd.dev/blog/how-to-find-and-fix-internal-linking-gaps-programmatically.

What I Actually Learned From This

  • <Link> over navigate() for anything user-facing. Reserve programmatic navigation for post-form-submit redirects, auth flows, and similar cases where there's no visual element to link from. If there's a card, button, or menu item a user can see, it should be a real anchor tag.

  • Orphaned pages hurt more than missing pages. If a page doesn't exist, Google just doesn't index it. If a page exists but can't be reached by following links, Google might index it once from the sitemap, then deprioritize it because nothing points to it. Low PageRank by default.

  • Run the audit before launch, not after. I didn't check until I noticed the indexing gap three weeks post-launch. Running a link audit on localhost:3000 before deploying takes five minutes and saves weeks of confusion.

  • Automated CI checks are the only checks that actually run. Adding the seo:check script to the build pipeline meant I stopped thinking about it manually and that's the point.

Let's Talk

Have you run into this problem in your own React projects? I'm curious, is the button onClick pattern something you've inherited from an existing codebase, or something that crept in organically?

Drop your experience in the comments. I'd especially love to hear if there are other common React SEO mistakes you've caught the hard way, I'm sure I haven't found all of mine yet.