Every week I send a newsletter to ~2,000 subscribers. For months I had zero idea which links people actually clicked. Open rates? Sure, my ESP gives me that. But click-level data per link? That was a black box.
I fixed it with a lightweight, open-source-friendly setup that takes about 15 minutes. Here's exactly how.
The Problem With Newsletter Link Tracking
Most email platforms (Substack, ConvertKit, Beehiiv) give you aggregate click counts. But they won't tell you:
- Which specific links get the most engagement
- Click velocity (how fast people click after open)
- Geographic breakdown per link
- Device/browser split per link
- Long-tail clicks days after sending
If you're promoting products, writing sponsored posts, or just trying to figure out what your audience cares about — you need per-link analytics.
The Stack
Here's what I use:
- A link shortener with analytics — I use Dub.co (free tier is generous, API-first)
- A simple Node.js script to auto-generate tracked links before each send
- A JSON file that logs everything for my own dashboards
Total cost: $0. Total setup time: ~15 minutes.
Step 1: Generate Tracked Links via API
Instead of manually creating short links, I wrote a script that takes my raw newsletter URLs and returns tracked versions.
First, grab a free API key from Dub.co. They have a free tier that covers most newsletter use cases — custom domains, analytics, and API access included.
// track-links.js
const DUB_API_KEY = process.env.DUB_API_KEY;
async function createTrackedLink(url, tag) {
const res = await fetch('https://api.dub.co/links', {
method: 'POST',
headers: {
'Authorization': `Bearer ${DUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
tag,
// optional: set a custom short slug
// key: 'my-custom-slug',
}),
});
const data = await res.json();
return data.shortLink;
}
// Usage: convert all links in your newsletter draft
async function trackNewsletterLinks(links) {
const tracked = {};
for (const [label, url] of Object.entries(links)) {
tracked[label] = await createTrackedLink(url, 'newsletter-issue-42');
console.log(`${label}: ${tracked[label]}`);
}
return tracked;
}
// Example
trackNewsletterLinks({
'main-article': 'https://myblog.com/post/scaling-postgres',
'sponsor': 'https://sponsor.example.com/promo',
'twitter-thread': 'https://x.com/me/status/123456',
'tool-recommendation': 'https://some-saas.com',
});
Run it before each send:
export DUB_API_KEY="your_key_here"
node track-links.js
Output:
main-article: https://dub.sh/abc123
sponsor: https://dub.sh/def456
twitter-thread: https://dub.sh/ghi789
tool-recommendation: https://dub.sh/jkl012
Replace the raw URLs in your newsletter with these. Done.
Step 2: Pull Analytics After Sending
Here's where it gets useful. After your newsletter goes out, pull the click data:
// pull-stats.js
async function getLinkStats(linkId) {
const res = await fetch(
`https://api.dub.co/analytics?linkId=${linkId}&event=clicks&interval=7d`,
{
headers: { 'Authorization': `Bearer ${process.env.DUB_API_KEY}` },
}
);
return res.json();
}
async function getClicksByCountry(linkId) {
const res = await fetch(
`https://api.dub.co/analytics?linkId=${linkId}&event=clicks&groupBy=countries`,
{
headers: { 'Authorization': `Bearer ${process.env.DUB_API_KEY}` },
}
);
return res.json();
}
I run this 24 hours after each send and log the results:
const fs = require('fs');
async function logStats(issueNumber, links) {
const stats = {};
for (const [label, linkId] of Object.entries(links)) {
stats[label] = {
clicks: await getLinkStats(linkId),
countries: await getClicksByCountry(linkId),
pulledAt: new Date().toISOString(),
};
}
const filename = `stats/issue-${issueNumber}.json`;
fs.writeFileSync(filename, JSON.stringify(stats, null, 2));
console.log(`Stats saved to ${filename}`);
}
Step 3: Spot Patterns Over Time
After a few issues, the data tells a clear story. Here's what I learned from mine:
| Link Type | Avg CTR | Insight |
|---|---|---|
| Tutorial links | 12.3% | Audience wants hands-on content |
| Tool recommendations | 8.7% | Good, especially with context |
| Twitter threads | 2.1% | People don't leave email for X |
| Sponsor links | 5.4% | Better when placed mid-content |
This completely changed how I structure my newsletter. I moved sponsor links from the footer to the middle of the content and saw a 2.5x increase in sponsor clicks.
Why Dub.co Over Other Options
I tried Bitly, TinyURL, and a self-hosted YOURLS instance before settling on Dub.co. Here's why:
- Free custom domain — your links look professional, not like generic short links
- API-first — everything I showed above works because the API is clean and well-documented
- Real analytics — geographic, device, referrer, time-series data out of the box
- Open source — the core is on GitHub, so you can self-host if you want full control
- Tags — I tag every link with the issue number, so I can filter analytics per newsletter
Bitly charges $35/mo for features Dub.co gives you free. YOURLS works but requires server maintenance. Dub.co hit the sweet spot.
Bonus: Automate With a Cron Job
I have a cron job that pulls stats for every issue automatically:
# crontab -e
0 9 * * MON node /path/to/pull-stats.js --issue latest
Every Monday morning I have fresh click data waiting in my stats/ folder. I glance at it with my coffee and know exactly what resonated.
Wrapping Up
If you're sending a newsletter without per-link tracking, you're flying blind. This setup is:
- Free (Dub.co free tier + Node.js)
- Fast (15 min setup, <1 min per issue after that)
- Private (your data, your JSON files, no third-party dashboard lock-in)
Grab a free Dub.co API key at dub.co and start tracking. Your newsletter strategy will thank you.
Have questions about the setup? Drop a comment — happy to share my full config.
Top comments (0)