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.
| Measurement | Observed |
|---|---|
| Redirect defined in config, served status | 200 (expected 301) |
| Security headers present in response | 0 of 5 |
| Service links in server-rendered HTML | absent |
| Inbound internal links, deep service pages | 1–3 |
| Pages with a hand-written related array | 14 |
Implementation
- 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
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
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
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
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
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
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 accepted | What it cost |
|---|---|
| Curated link ordering was lost on the existing 22 pages | Generated 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 one | Slightly 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 shared | Filter 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 floor | They 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.
| Measured | Before | After |
|---|---|---|
| Inbound links, deep service pages | 1–3 | 21 |
| Inbound links, AI Automation spokes | 1 (worst case) | 6–8 (evenly distributed) |
| Pages with hand-written link arrays | 14 | 0 |
| Redirect defined in config, served status | 200 | 301 |
| Security headers in response | 0 of 5 | 5 of 5 |
| Audit checks | 19 | 22 |
| Total pages | 22 | 35 |
| Manual link edits across 12 new resources | n/a | 0 |
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
Technical SEO checklist for Next.js sites
The framework-specific failures behind this work, as a checklist.
Read moreSEO audit: what to check, in what order
Ordered by dependency, so the first failing stage is the finding.
Read moreWebsite performance optimization
The companion case study: caching, stale builds and verification.
Read moreQuestions 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.
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