DEV Community

Nabeel Hassan
Nabeel Hassan

Posted on

My Blog's Internal Links Only Point Backward in Time

Upwork Scout, my AI-matched Upwork job alert tool, has a blog with 107 posts. Most of them were drafted by a scheduled AI writing routine that runs once a day: pick a topic, write the markdown, add a registry entry, link to a few related posts, commit, deploy. I review what goes out, and for months I thought the system was in good shape.

Then I wrote a 30-line script to count the internal links, and found out the blog has a direction. Every link in it points into the past.

This is a short build-log about how that happened, why neither the code nor the routine could see it, and what the fix looks like.

The setup

The blog engine is deliberately boring. Each post is a markdown file in content/blog/<slug>.md, and all the metadata lives in one TypeScript array:

export interface PostMeta {
  slug: string;
  title: string;
  description: string;
  date: string;       // ISO publish date
  modified?: string;
  keywords: string[];
  readMinutes: number;
  faq: FaqItem[];
}

export const POSTS: PostMeta[] = [ /* 107 entries, newest first */ ];
Enter fullscreen mode Exit fullscreen mode

Next.js renders every post statically at build time. generateStaticParams reads POSTS, the sitemap reads POSTS, and the FAQ array renders twice from the same data: once as a <details> list on the page and once as FAQPage JSON-LD. One source of truth, no CMS, no runtime cost.

I still like this design. lib/blog.ts is now about 2,600 lines, which sounds bad, but it is mostly FAQ text and the type checker catches a missing field the moment the routine forgets one.

And the obvious integrity checks pass. My script confirmed:

  • 107 registry entries, 107 markdown files, no orphans either way
  • no duplicate slugs
  • dates strictly newest-first
  • 1,187 internal links in post bodies, zero broken

So by every check I would have thought to write, the blog is healthy. The problem is in the shape, not in any single link.

Finding 1: links only go backward

Here is the part that should have been obvious. When the routine writes today's post, it links to posts that already exist. It cannot link to tomorrow's post. And nothing ever goes back to edit an old post to point at a new one.

So out of 1,187 body links, the number pointing to a newer post is exactly zero.

Over time that turns into a steep gradient. Average inbound body links per post, grouped by the month it was published:

Month Posts Avg inbound links
July 24 28.3
August 19 9.4
September 22 8.5
October 30 4.5
November 12 0.8

The top post, a guide on spotting good clients, has 83 inbound links. Thirteen posts have none at all, and 22 have one or fewer. Almost all of those are from the last month.

That is not because the new posts are worse. Some of them are more specific and more useful than the July ones. They just arrived after everyone else had already finished talking.

Finding 2: "Keep reading" made it worse, in the opposite direction

Every post ends with a "Keep reading" block. This is the line that fills it:

const related = POSTS.filter((p) => p.slug !== slug).slice(0, 3);
Enter fullscreen mode Exit fullscreen mode

POSTS is sorted newest first. So "related" means "the three newest posts", on every page.

I checked it live. A late-October post about applying to old jobs ends with links to a post about client calls, a post about ghosting after interviews, and a post about urgent jobs. All three were published this week. None of them have anything to do with applying to old jobs.

So I had two biases stacked on top of each other:

  • Body links pile up on the oldest posts and never reach new ones.
  • The footer sends all 106 other pages to whatever the newest three posts are today, and the set rotates every time a post ships.

The posts in the middle get neither. Once a post falls out of the newest three, it loses ~100 footer links overnight, and it has not been around long enough for later posts to start linking to it in their bodies. A good chunk of October is sitting in that gap.

It also means the "related" links on any given page change every day for reasons that have nothing to do with the page. I do not have search data that proves this hurt rankings, so I am not going to claim it did. But I would not design a link structure this way on purpose, and I did not. It fell out of a one-line default.

Why nothing caught it

Every individual piece was correct:

  • The routine was told to add internal links to existing articles, and it did, every single day.
  • The .slice(0, 3) line does exactly what it says.
  • The build passes, the type checks pass, no links 404.

The failure only exists in aggregate. No single commit introduced it; 107 commits did, each one fine. A code reviewer looking at any one diff would approve it. This is the same lesson I keep relearning with automated pipelines: the per-run checks tell you each run worked, not that the thing they are building over time has the shape you wanted. I wrote about a cousin of this problem in My Live Job Pages Are All Empty, and My Code Cannot Tell Me Why, where four different failures rendered the identical page.

Finding 3, smaller: hand-typed read times

While I had the script open, I compared each post's readMinutes against its word count at roughly 230 words a minute. 92 of 107 are within a minute, which is fine. But the earliest launch posts are the worst: a ~900-word filters guide claims 10 minutes, and a few others claim 8 or 9 for about four minutes of reading. A value that can be computed should not be typed.

What the fix looks like

None of this is shipped yet. This is the plan, written down partly so I actually do it.

1. Make "related" mean related. Every post already has a keywords array. Score other posts by keyword overlap and fall back to recency only for ties:

function relatedPosts(post: PostMeta, n = 3): PostMeta[] {
  const mine = new Set(post.keywords.flatMap((k) => k.split(" ")));
  return POSTS.filter((p) => p.slug !== post.slug)
    .map((p) => ({
      p,
      score: p.keywords
        .flatMap((k) => k.split(" "))
        .filter((w) => w !== "upwork" && mine.has(w)).length,
    }))
    .sort((a, b) => b.score - a.score || (a.p.date < b.p.date ? 1 : -1))
    .slice(0, n)
    .map((x) => x.p);
}
Enter fullscreen mode Exit fullscreen mode

Dropping "upwork" matters, since it appears in nearly every keyword and would make everything look related to everything. This is crude, but it is stable (a page's related links stop changing daily) and it is topical. Keywords can be weak, so I will spot-check the output before trusting it.

2. Make the routine write forward links too. The daily routine already reads two or three existing posts before writing. The change is one more step: after writing the new post, pick two older posts whose topic it extends and add one natural sentence linking forward to it. That converts every publish from "one new node with outgoing edges" into "one new node with edges in both directions".

3. Put the link graph in the build, not in my head. The audit script becomes a check that runs before deploy and fails or warns when:

  • a post is older than two weeks and has zero inbound body links
  • any body link targets a slug that is not in POSTS
  • readMinutes drifts more than two minutes from the computed value

The broken-link check already passes, but it is the kind of thing that passes right up until a slug gets renamed.

4. Compute readMinutes. Read the markdown at build time and derive it. One less field for a human or a model to get wrong.

The general lesson

If a system grows by appending, check what the append-only rule does to the shape of the whole thing. Blogs, changelogs, docs sites, knowledge bases for AI agents: anything where each new entry can reference the past but the past never learns about the new entry will drift the same way. The individual writes are all valid. The structure still tilts.

I only found it because I stopped asking "does each post work?" and asked "what does the whole graph look like?" That script took ten minutes to write and was more useful than any review of a single post.

If you want to see the blog in question, it lives at upwork-scout.com/blog. The fix will be in there soon, and the "Keep reading" links will finally have something to do with what you just read.

Have you hit this on your own content sites? I am curious whether anyone automates the forward-linking step, or whether everyone just does a manual internal-link pass every few months.

Top comments (0)