September 11, 2026·6 min read

Internal Links That Actually Render: The Empty Related-Card Trap

By Andrew Pyle

My internal links didn't help because they never reached the HTML a crawler receives. I had a whole feature for it, a relationship model, a serializer, a rendered card, and it produced zero links Google could see, for three separate reasons stacked on top of each other.

01

The lever I reached for

I wanted to strengthen internal linking across my 52 essays. The obvious place to look was the "Related" card each essay page renders at the bottom: a handful of sibling posts, pulled from a relationship table, meant to send a reader (and a crawler) somewhere else on the site. All the pieces were there. A Django model to hold the relationships. A DRF serializer to shape them for the API. A React component that renders the card. On paper this was already built. I just needed to use it.

That's the trap with a feature you built months ago and haven't looked at since: "it exists" and "it works" are different claims, and I'd only ever checked the first one.

02

Three bugs, not one

The first problem was the simplest: the relationship table was empty for my essays. Nobody had ever populated it. The model existed, the migration had run, and there were zero rows. Every related card on every essay page was rendering nothing, because there was nothing to render.

The second problem was in the serializer. It built each related link's URL by hardcoding the prefix `/blog/`, a leftover from an earlier version of the site before I renamed the section to `/writing/`. So even a populated relationship would have produced a link to a 404. I would have fixed problem one and shipped broken links, which is arguably worse than shipping no links, because a 404 tells Google something concrete and wrong instead of nothing.

The third problem is the one that actually mattered, and it took me longer to find. I populated the relationship data, fixed the URL prefix, checked the page in a browser, and the related card rendered exactly as expected. Real links, right URLs, right anchor text. Then I checked the same page the way a crawler does, and the card wasn't there at all.

03

Same bug, different feature

I'd already been through this once. My essay bodies themselves used to have this exact problem: the page fetched its content asynchronously after mount, `renderToString` at build time never runs that fetch, and the prerendered HTML shipped to Google was an empty shell. I fixed that by baking the essay content into a build-time snapshot so it exists before the async step ever runs.

The related card ran into the same wall from a different direction. Even with real data behind it, the component that fetches related posts wasn't wired into the same build-time snapshot path as the essay body. It was still pulling its data on the client, after the page had already been frozen into static HTML. A browser runs that client-side code and shows you a correct page. A crawler, or a prerender snapshot, sees the page as it existed the instant before that fetch ran, which is to say, without the card's contents at all.

That's the distinction that actually matters here, and it's easy to get backwards: a page that looks right in a browser and a page that exists to a crawler are not the same claim. Anything that depends on an async fetch after the initial render is invisible to whatever reads your HTML without executing your JavaScript, no matter how correct it looks when you load the URL yourself.

04

What I checked, and how I checked it

I stopped trusting the browser and started curling the pages as Googlebot:

```bash curl -A "Googlebot" https://andrewjpyle.com/writing/<slug> \ | grep -A2 "related" ```

Nothing came back. Not a broken link, not a wrong anchor. Nothing. The related-card markup simply wasn't in the response body. That's the moment I understood the first two fixes had been necessary but not sufficient. I could have spent another afternoon tuning which essays link to which and it would have changed nothing a crawler would ever see.

05

The fix wasn't to debug the related card further. It was to stop routing new internal-link authority through it and use a mechanism that was already static by construction: inline links inside the essay body itself.

Each essay's body comes from a build-time snapshot, the same one that solved the original prerender problem. That means anything I add to the body text, including a link, is guaranteed to be in the HTML a crawler receives, because it's part of the same content that gets baked in before the page is ever rendered. No async step between the link and the page. No component that has to run client-side to produce it.

So I added a short "Keep reading" block near the end of each essay, a few sentences of contextual links to genuinely related pieces, written into the body copy rather than rendered from a separate data structure. Same idea as the related card, different plumbing, and the plumbing is the whole story here.

06

Once the mechanism was solid, the interesting question became where to spend it. My two highest-traffic pages on the site aren't essays at all: a free-AI-tools roundup pulling roughly 24,000 monthly impressions, and a page about MCP certification. Neither of those pages had ever passed any of that traffic weight into my writing. I added inline "Keep reading" links from both of them into the on-brand essays most relevant to each, so some of the authority those pages already have starts flowing somewhere it hadn't before.

I also picked two essays to act as cluster hubs, meaning their sibling essays link up to them inline, rather than every essay linking sideways to every other essay in a flat, undifferentiated mesh. That gives the crawler (and a reader) a clear sense of which pages are central and which are supporting, instead of 52 essays that all look equally important because they're all equally connected to everything.

All of it inline, all of it in the static HTML, all of it verified the same way I found the bug: `curl -A "Googlebot"` against the live URL, reading what actually came back rather than trusting what I saw in a tab.

07

Bottom line

A feature that renders correctly in your browser can still be completely invisible to the thing you built it for. My related-card system had a model, a serializer, and a component, and it moved zero authority anywhere, because the data was missing, then the URLs were wrong, then the render path threw the whole thing away before a crawler ever saw it. Fixing all three in sequence is what it took to notice the third one was the only one that mattered. If you're trying to strengthen internal linking on anything that renders client-side, don't check the page. Check what the crawler gets back. Those are two different pages until you've proven otherwise.

---

Written by Andrew Pyle. I build and run this site's own infrastructure, including the prerender and internal-linking layer behind /writing, and I verify changes against what a crawler actually receives, not what renders in a browser tab.

Have something you need built or fixed?

I build production Django / Next.js platforms and human-supervised AI-agent systems. Solo, senior, and fast. Tell me what you are building.

Start a project