- 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));
}
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}
))}
);
}
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}
))}
);
})}
);
}
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();
Run it:
node scripts/validate-nav.ts
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"
}
}
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)