Add related reading by reusing the ranking you already trust
One of my side projects sends a short daily email: a handful of today’s stories, each with a one-line summary and a link. It works, but a daily email only ever shows today. If someone signs up on a slow news day, they get two stories and nothing else to click. The archive behind it has plenty of good older stories that never get a second look.
So I wanted a small “also worth reading” block at the bottom of each email with two or three more stories from the past week. It sounds like a template change. It’s really a second selection problem, and the way you solve it decides whether the block is useful or just noise.
The tempting shortcut
The quickest version is “grab the three newest stories that aren’t in today’s email.” That looks fine in a demo and goes wrong in production in a few predictable ways:
- It skips every rule the main selection follows. My daily picks already filter out stories that aren’t ready to publish, avoid sensitive topics, require a version written for the reader’s level, and cap how many come from one category. “Newest three” ignores all of that, so the related block ends up showing exactly the stories the main logic was careful to leave out.
- Newest isn’t best. Recency is one input to the ranking, not the whole thing.
- It drifts. Any time the main rules change, the shortcut doesn’t, and now there are two slightly different ideas of what a good story is.
The fix is to not write a second ranking at all.
Reuse the selection function
I already had one function that takes a pool of stories and returns the best few for a reader’s level: it applies eligibility rules, scores by freshness and quality, and limits picks per category. The related block calls that same function with a different pool and slightly different knobs:
function selectRelated(stories, level, { exclude = [], now, days = 7, count = 3 } = {}) {
const key = (s) => s.id ?? s.url;
const shown = new Set(exclude.map(key));
const pool = stories.filter((s) => !shown.has(key(s)));
return selectStories(pool, level, { now, days, count, perCategory: 2 });
}
That’s the whole feature on the selection side. A few details matter more than they look:
- Exclude by a stable key. Compare ids, not object identity. The stories in today’s picks may be copies or re-fetched objects, and an identity check would let the same story appear twice in one email. I fall back to the URL when an id is missing because a duplicate link in an email looks careless.
- Exclude before ranking, not after. If you rank first and then drop the stories that are already shown, you can end up with one related link instead of three. Filtering the pool first means the ranking always has a full set to choose from.
- Tighten the per-category cap. Three related stories from the same category reads like a bug. Lowering the cap from three to two for this block keeps it varied without inventing new rules.
- Scale the count to the email. On a day with plenty of new stories I show two related links; on a thin day I show three. The block exists to fill thin days, so it should do more work on them.
Widen the fetch, not the selection
The part that bit me: the email job only fetched the last two days of stories, because that was all the daily picks needed. A related block that looks back a week has nothing to look at if the data was never loaded.
The fix was to widen the fetch window to eight days while leaving the daily selection exactly as it was. Today’s picks still filter to today’s window; the related block gets the whole week. Keeping those two windows separate is the important part. If you widen one shared window, last Tuesday’s story quietly shows up as “today.”
If fetching more is expensive, cache it or fetch the related pool separately. For a few hundred rows it isn’t worth the complexity.
Tag the links so you can tell if it works
A related block is a guess about what readers want. I want to know whether anyone clicks it, separately from the main stories. Every link in the email already carries campaign parameters, so the related links reuse them and add one more:
function relatedUrl(story, campaign) {
const url = new URL(storyUrl(story, campaign));
url.searchParams.set('utm_content', 'related');
return url.toString();
}
Using URL and searchParams instead of string concatenation means I never produce a link
with two question marks or an unencoded value. And because the main links and the related
links share the same campaign, a single report shows how the block performs next to the
stories above it. If it never gets clicks after a few weeks, that’s a clear signal to cut it.
Render nothing when there’s nothing
The template only renders the block when the list is non-empty. A heading with nothing under it is worse than no block. This matters most for brand new projects or test data, where the archive is short and the related pool can legitimately be empty.
Test the rules, not the HTML
The test I wrote checks behavior, not markup:
test('related stories exclude today and carry tracking', () => {
const today = story({ createdAt: '2026-01-12 01:00' });
const older = [1, 2, 3, 4].map((d) => story({ createdAt: `2026-01-0${5 + d} 10:00` }));
const stale = story({ createdAt: '2025-12-20 10:00' });
const related = selectRelated([today, ...older, stale], 'A2', { exclude: [today], now });
assert.equal(related.length, 3);
assert.ok(!related.some((s) => s.id === today.id || s.id === stale.id));
const html = renderDigest({ stories: [today], related });
assert.equal((html.match(/utm_content=related/g) || []).length, 3);
assert.doesNotMatch(renderDigest({ stories: [today] }), /also worth reading/i);
});
It covers the four things that would actually embarrass me in someone’s inbox: repeating a story they just read, pulling in something stale, links that can’t be measured, and an empty heading.
Tradeoffs
- It shares a ranking, so it shares its blind spots. If the main selection overweights one kind of story, so will the related block. That’s a feature for consistency and a limitation for discovery.
- The same reader can see the same related story on several days. For a week long window that’s acceptable. If it starts to feel repetitive, the next step is to exclude stories that were in the last few emails, which means keeping a small send history.
- “Related” here means “also good,” not “similar.” True topical similarity needs tags or embeddings and is a much bigger project. For a short daily email, a well ranked pick from the past week does most of the job.
The checklist I’ll reuse
- Call the existing selection function with a different pool; don’t write a second ranking.
- Exclude already shown items by a stable key, before ranking.
- Fetch enough data for the widest window, and keep each selection’s window separate.
- Tag the new links so the block can be measured on its own.
- Render nothing when the list is empty.
- Test exclusion, staleness, tracking, and the empty case.