DEV Community

Sathish
Sathish

Posted on

Next.js internal linking: I shipped it fast

  • I generate internal links from one config file.
  • I keep links consistent across header/footer.
  • I validate them with a script, not vibes.
  • This fixed orphan pages and crawl paths fast.

Context

I build Next.js sites with lots of pages.
State pages. City pages. Category pages. Thousands of detail pages.

And I kept doing the dumb thing.
I’d ship pages. Then forget to link to them.

Search Console would show the same pattern.
“Discovered — currently not indexed.” Or pages indexed but never getting crawled again.
Brutal.

I tried to “just add links where it makes sense”.
That turned into random links, inconsistent labels, and a footer that grew like a junk drawer.
Spent 4 hours on this. Most of it was wrong.

So I made internal linking boring.
A config file. A few small components. One validation script.

1) I wrote one link map. Then stopped improvising

I don’t want internal links spread across 12 components.
I also don’t want marketing copy in my navigation.
So I keep a single typed map.

It’s not fancy.
It’s just the source of truth.

// lib/nav.ts
export type NavItem = {
  label: string;
  href: string;
  // optional: where this should appear
  placements: Array<"header" | "footer" | "homepage">;
};

export const NAV_ITEMS: NavItem[] = [
  { label: "Browse by Job Type", href: "/categories", placements: ["homepage"] },
  { label: "Travel / Locum", href: "/categories/travel", placements: ["homepage", "footer"] },
  { label: "New Grad", href: "/categories/new-grad", placements: ["homepage", "footer"] },
  { label: "Per Diem", href: "/categories/per-diem", placements: ["homepage", "footer"] },
  { label: "Telehealth", href: "/categories/telehealth", placements: ["homepage", "footer"] },

  { label: "States", href: "/states", placements: ["footer"] },
  { label: "Cities", href: "/cities", placements: ["footer"] },
];

export function navFor(placement: NavItem["placements"][number]) {
  return NAV_ITEMS.filter((i) => i.placements.includes(placement));
}
Enter fullscreen mode Exit fullscreen mode

Cursor + Claude helped here.
Not with “generate the perfect nav”.
With the annoying refactors.
Rename a route. Update all uses. Fix types.

One thing that bit me — I originally stored full URLs.
Then I changed domains in staging.
Instant mess.
Paths only. Always.

2) I made the homepage links a real component

I used to hardcode the homepage section.
Then I’d add a new category page and forget the homepage.
Classic.

Now the homepage pulls from the same map.
Same href. Same label. Same casing.

// components/HomeBrowseLinks.tsx
import Link from "next/link";
import { navFor } from "@/lib/nav";

export function HomeBrowseLinks() {
  const items = navFor("homepage");

  return (


        Browse by Job Type



        {items
          .filter((i) => i.href.startsWith("/categories/"))
          .map((i) => (
            -                               {i.label}                          
          ))}


  );
}
Enter fullscreen mode Exit fullscreen mode

Simple.
But it fixed a real workflow problem.
I add one item in NAV_ITEMS and it shows up.

Also.
Don’t put 200 links on the homepage.
I tried.
It looked like a phone book.
I keep it to the handful that actually matters.

3) I fixed the footer so it doesn’t turn into chaos

Footer links are where order goes to die.
Teams dump everything there.
Solo builders do it too. I did.

I wanted:

  • grouped sections
  • consistent ordering
  • no accidental duplicates

So the footer renders groups from the same map.

// components/SiteFooter.tsx
import Link from "next/link";
import { navFor } from "@/lib/nav";

type Group = { title: string; match: (href: string) => boolean };

const GROUPS: Group[] = [
  { title: "Categories", match: (h) => h.startsWith("/categories/") },
  { title: "Locations", match: (h) => h === "/states" || h === "/cities" },
];

export function SiteFooter() {
  const items = navFor("footer");

  return (


        {GROUPS.map((g) => {
          const links = items.filter((i) => g.match(i.href));
          if (links.length === 0) return null;

          return (

              {g.title}

                {links.map((i) => (
                  -                                           {i.label}                                      
                ))}


          );
        })}


  );
}
Enter fullscreen mode Exit fullscreen mode

The trick is the grouping.
Not the CSS.

And yeah — grouping by href patterns feels gross.
But it’s explicit.
And it’s stable.

If you want to be cleaner, add a group field to NavItem.
I didn’t.
I wanted one less field to maintain.

4) I stopped trusting myself and wrote a link validator

This is the part people skip.
Then they ship a broken link to /categoires/new-grad.
Ask me how I know.

I validate three things:

  • duplicates
  • missing leading slash
  • route existence (basic check)

Next.js doesn’t expose a clean “list of routes” at runtime.
So I validate against a file-based allowlist.
It’s annoying.
It also saved me twice this week.

// scripts/validate-nav.ts
import { NAV_ITEMS } from "../lib/nav";
import { existsSync } from "node:fs";
import path from "node:path";

const appDir = path.join(process.cwd(), "app");

function routeExists(href: string) {
  // Very basic: checks for app//page.tsx
  // Works for simple static routes like /states or /categories/telehealth
  const clean = href.replace(/\?.*$/, "").replace(/#.*$/, "");
  const dir = clean === "/" ? appDir : path.join(appDir, clean);
  return (
    existsSync(path.join(dir, "page.tsx")) ||
    existsSync(path.join(dir, "page.jsx"))
  );
}

function main() {
  const seen = new Set();
  const errors: string[] = [];

  for (const item of NAV_ITEMS) {
    if (!item.href.startsWith("/")) errors.push(`Missing leading '/': ${item.href}`);
    if (seen.has(item.href)) errors.push(`Duplicate href: ${item.href}`);
    seen.add(item.href);

    // skip dynamic routes. example: /jobs/[id]
    if (item.href.includes("[")) continue;

    if (!routeExists(item.href)) errors.push(`Route not found in /app: ${item.href}`);
  }

  if (errors.length) {
    console.error("Nav validation failed:\n" + errors.map((e) => `- ${e}`).join("\n"));
    process.exit(1);
  }

  console.log(`Nav validation OK. Checked ${NAV_ITEMS.length} items.`);
}

main();
Enter fullscreen mode Exit fullscreen mode

Run it:

node scripts/validate-nav.ts
Enter fullscreen mode Exit fullscreen mode

I wired it into package.json.
So I can’t “forget”.

{
  "scripts": {
    "check:nav": "node scripts/validate-nav.ts",
    "build": "npm run check:nav && next build"
  }
}
Enter fullscreen mode Exit fullscreen mode

Yeah, it makes builds fail.
Good.
I want the pain early.

One more thing that wasted my time.
I tried validating HTTP status codes by fetching my own site.
In CI.
Behind auth.
On preview deployments.
It was a rabbit hole.
File existence checks got me 80% of the value.

Results

After the internal linking pass, I had 4 new category pages linked from the homepage and footer, plus cross-links to location hubs.
I also cleaned up navigation so it’s generated from one typed config.

In Search Console, the backlog of “discovered not indexed” pages started shrinking after I fixed linking + a few crawl blockers in robots.
Not magic.
Just crawl paths.

On analytics, weekly active users moved from about 58 to 163.
Average engagement went from ~27 seconds to 1m09s.
Still small numbers.
But at least the pages aren’t isolated anymore.

Key takeaways

  • Keep internal links in one file. Route paths only.
  • Render homepage + footer from the same map. No hand-edits.
  • Validate duplicates and route existence with a script.
  • Don’t try to “link everything”. Pick a few hubs.
  • If you’re shipping lots of pages, orphan pages are the default state.

Closing

Internal linking is boring.
That’s the point.
I want it automated, consistent, and hard to break.

How do you validate internal links in Next.js — do you rely on a crawler, or do you fail the build with scripts like this?

Top comments (0)