Skip to content

Case study — SEO architecture

Internal linking that cannot rot

A site with hand-written related-links arrays works until roughly sixty pages, then quietly stops. This documents replacing that with a generated graph, the silent configuration flag that had been discarding every redirect, and the decision to enforce editorial rules at build time rather than at review.

This is our own website, not a client engagement. Every figure below comes from the audit script in this repository run against real builds, and the internal link counts are reproducible by anyone crawling the live site. We publish it because architecture work on a site we control can be shown in full, including the mistakes.

Subject
vedixx.co — our own production site
Starting point
22 pages, hand-written link arrays
Stack
Next.js App Router, TypeScript, MDX
Constraint
Zero URL changes — site was already indexed

The problem

Every service page carried a hand-written array of related links. At 22 pages this was fine. The failure mode arrives later and arrives quietly: adding a page means editing every page that ought to link to it, nothing enforces that you did, and the pages nobody remembers to update become invisible to the link graph.

A crawl confirmed the shape of it. Deep service pages had between one and three inbound internal links. Some content existed on the site and was, for practical purposes, unreachable by anything following links.

Two further problems surfaced during the same investigation. The navigation dropdown rendered only after JavaScript ran, so the links to every service page were absent from the server-rendered HTML. And a single configuration flag was silently discarding every redirect and security header the project had written.

Investigation

The configuration finding was the most consequential and the least visible. Next.js discards the redirects, rewrites and headers functions entirely when static export is enabled. There is no build warning and no runtime error. The functions remain in the config file, read as correct in review, and never execute.

It was caught by testing behaviour rather than reading code: requesting a URL that had a redirect defined and observing the status. The redirect returned 200 instead of 301, and the security headers were absent from the response.

The navigation problem was found the same way — fetching the homepage and searching the raw HTML for a service link. The links were not there. A browser rendered them; a crawler reading the response would not.

The link distribution was measured by crawling the built site and counting inbound links per URL, which became the basis for a permanent audit check rather than a one-off investigation.

MeasurementObserved
Redirect defined in config, served status200 (expected 301)
Security headers present in response0 of 5
Service links in server-rendered HTMLabsent
Inbound internal links, deep service pages1–3
Pages with a hand-written related array14

Implementation

  1. 1

    Switch output mode so configuration actually applies

    Static export was replaced with standalone output. This is what makes redirects and headers functional rather than decorative. Every route remains statically prerendered, so nothing about page delivery changed — the difference is that HTTP-level rules now execute.

    output: 'export' → output: 'standalone'

  2. 2

    Ship navigation in the server-rendered HTML

    The dropdown was changed from conditional rendering to always present and hidden with CSS. The markup exists in the response; visibility is a presentation concern. Keyboard and focus handling were preserved so the change was invisible to users and total for crawlers.

    opacity-0 invisible → group-hover:visible group-focus-within:visible

  3. 3

    Build a service registry as the single source of structure

    Each service page was recorded once with its pillar and whether it is a hub or a spoke. Link generation reads from this registry, so adding a page makes every relevant sibling link to it without any existing page being edited.

  4. 4

    Generate structural links and delete the hand-written arrays

    Related links, sibling links and breadcrumbs became derived. The related arrays were removed from all 14 service pages in the same commit that introduced the generated graph — leaving both would have preserved exactly the drift the change existed to prevent.

  5. 5

    Fix the rotation flaw the first version introduced

    The initial generator sliced siblings in registry order and capped the list. That deterministically starved the end of each cluster: the last spokes never appeared in any sibling list and sat at a single inbound link. Rotating the list by each page's own position distributed links evenly while staying fully deterministic.

    Before rotation: two spokes at 1 inbound · After: all eight at 6

  6. 6

    Move editorial rules into build-time validation

    A closed topic vocabulary, unique primary keyword per page, a required reviewer on published content, and a required link from each resource to the commercial page it supports. Each is an error that fails the build rather than a guideline someone is expected to remember.

    Verified by deliberately breaking a file: 3 planted faults, 3 build failures

  7. 7

    Extend the audit with the checks the work made necessary

    An inbound-link floor, an outbound ceiling, and a check that breadcrumb structure matches URL depth. The audit reports its own check count rather than having a number written in prose, because a document claiming eighteen checks while the script runs nineteen is how teams stop trusting documentation.

Technical decisions

Generated structural links, hand-written editorial links

Why: Structure is mechanical and should be derived. Contextual links belong to the sentence they appear in and cannot be generated meaningfully. Separating them means the mechanical half cannot rot and the human half stays human.

Rejected: Generating everything, which produces link blocks that read as navigation and get scrolled past.

Remove the hand-written arrays in the same commit

Why: The generated graph's entire value is that it cannot drift. Two link systems with no rule about which wins is worse than either alone.

Rejected: Keeping them as a fallback during transition — which is how a temporary parallel system becomes permanent.

Closed topic vocabulary enforced at build time

Why: Free-text tags fragment into near-duplicates across authors and time. The failure is silent and takes about a year to become visible, by which point fixing it touches every file.

Rejected: Free tags with periodic cleanup, which is permanent work rather than a one-time constraint.

Client-side only filtering on the resources index

Why: URL-based facets would generate a combinatorial set of thin, near-duplicate crawlable pages competing with each other. Filter state is a browsing convenience, not a destination.

Rejected: Query-string filters, which would have reintroduced through the back door the thin-page problem the URL design had already rejected.

Self-referencing canonicals on pagination

Why: Canonicalising page two onward to page one declares that the content on later pages does not exist — which, for a library whose evergreen material drifts backwards over time, hides precisely what should be found.

Rejected: Canonicalising to page one, still widely recommended and actively harmful for this content shape.

Organization rather than ProfessionalService in structured data

Why: ProfessionalService is a LocalBusiness subtype and is invalid without a postal address. The business is not yet registered, so the more specific type described an entity we could not evidence.

Rejected: Keeping the more specific type because it sounds better, which is markup claiming something the site cannot support.

Trade-offs accepted

What we acceptedWhat it cost
Curated link ordering was lost on the existing 22 pagesGenerated ordering ranks by topic overlap rather than editorial judgement. Consistency at scale was judged more valuable than curation at 22 pages.
Adding a topic requires two edits rather than oneSlightly slower to add a genuinely new topic. That friction is the mechanism preventing vocabulary fragmentation, so it is the feature rather than the price.
Resource filters cannot be linked or sharedFilter state is lost on refresh. Accepted because the alternative was a crawlable surface of near-duplicate pages.
Two service pages remain below the inbound-link floorThey belong to a cluster with no supporting content yet. The audit reports them on every run rather than the threshold being lowered to make the warning disappear.

Outcome

The navigation change alone took inbound internal links on deep service pages from between one and three up to 21, because every page on the site now links to every service page in its server-rendered HTML.

The generated graph then made content additions compound. Publishing a resource strengthens the commercial page it supports and the siblings it shares topics with, without any existing page being edited. Across twelve resources published afterwards, not one required a manual link edit anywhere on the site.

The validation layer has caught real faults rather than theoretical ones — an unknown topic, a missing reviewer, and a resource that did not link to the commercial page it claimed to support all failed the build before reaching production.

MeasuredBeforeAfter
Inbound links, deep service pages1–321
Inbound links, AI Automation spokes1 (worst case)6–8 (evenly distributed)
Pages with hand-written link arrays140
Redirect defined in config, served status200301
Security headers in response0 of 55 of 5
Audit checks1922
Total pages2235
Manual link edits across 12 new resourcesn/a0

Lessons learned

A configuration flag can disable features without any warning

Static export silently discards redirects, rewrites and headers. The code stays in the file and passes review. The only way to catch it is to test the behaviour on the deployed site rather than read the configuration.

Deterministic generation can still be systematically unfair

Slicing siblings in registry order was correct, repeatable, and starved the same pages every single time. Determinism guarantees consistency, not fairness, and the audit surfaced it where code review had not.

Rules enforced at review get broken under deadline

Unique keywords, closed topics, a required reviewer. Each was documented first and enforced later. Only the enforced versions survived contact with a deadline.

Migrating a link system means deleting the old one immediately

Keeping hand-written arrays as a transitional fallback would have preserved exactly the drift the generated graph was built to eliminate. The safe-feeling option was the one that defeated the purpose.

Server-rendered navigation is worth more than it appears

One CSS change moved deep pages from near-invisible to fully linked. It was the highest-return change in the entire project and it altered nothing a user could perceive.

Technology used

  • Next.js App Router
  • TypeScript
  • MDX with YAML frontmatter
  • JSON-LD structured data
  • Schema.org entity graph with @id references
  • Build-time content validation
  • Custom crawl and audit tooling
  • GitHub Actions

Related

FAQ

Questions about this work

Because the failure is silent and arrives later. At 22 pages the arrays were accurate. At 200 the cost is that adding a page requires editing every page that should link to it, with nothing enforcing it — and the pages nobody remembers become invisible. Migrating at 22 pages cost a day; at 200 it would have been a project.

Limited spots for new growth partners

Let's Turn Your Traffic Into Revenue.

Book a free 30-minute strategy call. We'll audit your current growth, spot the biggest opportunities, and map a clear plan, no pressure, just value.

Free audit · Custom plan · Clear pricing & timelines

Chat with us