DEV Community

Joshua
Joshua

Posted on

How I Track Every Click on My Newsletter Links (Open-Source Setup)

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:

  1. A link shortener with analytics — I use Dub.co (free tier is generous, API-first)
  2. A simple Node.js script to auto-generate tracked links before each send
  3. 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',
});
Enter fullscreen mode Exit fullscreen mode

Run it before each send:

export DUB_API_KEY="your_key_here"
node track-links.js
Enter fullscreen mode Exit fullscreen mode

Output:

main-article: https://dub.sh/abc123
sponsor: https://dub.sh/def456
twitter-thread: https://dub.sh/ghi789
tool-recommendation: https://dub.sh/jkl012
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)