Blog
analytics

Building a Demand-Driven Content Pipeline with PostHog and Cloudflare Workers

How I built an automated content pipeline that turns PostHog search analytics into GitHub issues using Cloudflare Workers for demand-driven font additions.

Mladen Ruzicic
Mladen Ruzicic
7 min

This post covers the technical implementation. For the strategic context, see The Content Flywheel.

FontAlternatives has 303 premium fonts with free alternatives. But there are thousands of fonts I haven’t covered yet. How do I know which ones to add next?

I could guess. I could research Google Trends. I could scrape design blogs.

Instead, I let users tell me directly through their search behavior.

The problem

I don’t know which fonts users actually want. Adding fonts takes time: researching alternatives, finding similarity scores, sourcing specimen images, writing descriptions. I don’t want to spend hours adding “Papyrus alternatives” if nobody’s looking for them.

Traditional approaches:

  • Traffic analysis: Which pages get visits? (Only tells me about fonts I already have)
  • Keyword research: What do people search on Google? (Expensive, noisy, competitive)
  • Guessing: Add popular foundries? (Assumes popularity = demand for alternatives)

None of these tell me what users want but can’t find.

The solution: index what you don’t have

My search index includes 6,198 fonts I don’t have pages for.

interface SearchableFont {
  name: string;
  slug: string;
  type: "premium" | "free" | "unavailable";  // <-- This is key
  classification: string;
  url: string;  // Empty for unavailable
}

When users search:

  1. Fuse.js fuzzy matches against all 7,000+ fonts
  2. Unavailable fonts appear with a “Coming soon” badge
  3. These results are not clickable (no page exists)
  4. PostHog tracks unavailable_font_searched events (via the PostHog proxy to capture events even from users with ad blockers)
trackUnavailableFontSearched({
  query: "avenir",
  font_name: "Avenir",
  font_slug: "avenir",
  classification: "sans-serif"
});

Why not just show them later?

You might ask: why show fonts you don’t have? Isn’t that frustrating UX?

Here’s my reasoning:

1. Acknowledgment > nothing

Users searching for “Avenir” want to know if I have it. Showing “Avenir - Coming soon” is better than “No results found.” It says: “I know this font exists, I just haven’t covered it yet.”

2. Demand signal

Every search for an unavailable font is a data point. Without indexing them, I’d never know these users visited.

3. Not SEO-indexed

These “Coming soon” results don’t create pages. They’re not in the sitemap. Google never sees them. This is purely a user-facing signal capture mechanism.

The demand pipeline

Once I have search data, what do I do with it?

flowchart TD
    A[User searches 'Avenir'] --> B[PostHog event: unavailable_font_searched]
    B --> C[Daily cron job queries PostHog]
    C --> D{5+ searches in 30 days?}
    D -->|Yes| E[Create GitHub issue automatically]
    D -->|No| F[Wait for more demand]
    E --> G[Claude Code processes issue]
    G --> H[PR created & deployed]

    style A fill:#e3f2fd
    style H fill:#c8e6c9

The Cloudflare Worker

A scheduled Worker runs daily at 9 AM UTC:

// workers/font-demand-issues.ts

const DEMAND_THRESHOLD = 5;  // Minimum searches to trigger
const LOOKBACK_DAYS = 30;
const MAX_ISSUES_PER_RUN = 3;  // Rate limiting

async function handleScheduled(env: Env) {
  // Query PostHog for unavailable font searches
  const fontCounts = await getTopSearchedFonts(env.POSTHOG_API_KEY);

  // Filter fonts above threshold
  const highDemandFonts = Array.from(fontCounts.entries())
    .filter(([_, count]) => count >= DEMAND_THRESHOLD)
    .sort((a, b) => b[1] - a[1]);

  for (const [fontName, searchCount] of highDemandFonts) {
    // Check KV to avoid duplicates
    const existing = await env.FONT_DEMAND_KV.get(`issue:${slug}`);
    if (existing) continue;

    // Check GitHub for existing issue
    const hasIssue = await issueExists(env.GITHUB_TOKEN, fontName);
    if (hasIssue) continue;

    // Create the issue
    await createIssue(env.GITHUB_TOKEN, fontName, searchCount);

    // Rate limit: max 3 issues per run
    if (++issuesCreated >= MAX_ISSUES_PER_RUN) break;
  }
}

The issue template

Each auto-created issue includes:

## Font Request: Avenir

This font has been searched **12 times** in the last 30 days.

| Field | Value |
|-------|-------|
| **Name** | Avenir |
| **Slug** | `avenir` |
| **Searches** | 12 (last 30 days) |

## Checklist
- [ ] Identify foundry
- [ ] Classification: sans-serif | serif | display | mono
- [ ] Find 2-3 free alternatives with similarity scores
- [ ] Create premium font file
- [ ] Link free alternatives
- [ ] Download specimen images

@anthropics/claude-code-reviewers - Please implement this font request.

The @anthropics/claude-code-reviewers mention triggers Claude Code to automatically process the issue, create the font files, and open a PR.

Deduplication layers

I don’t want 50 issues for the same font. Three deduplication layers:

1. KV namespace

Before creating an issue, check Cloudflare KV:

const existing = await env.FONT_DEMAND_KV.get(`issue:avenir`);
if (existing) return;  // Skip

// After creating issue:
await env.FONT_DEMAND_KV.put(`issue:avenir`, issueNumber, {
  expirationTtl: 60 * 60 * 24 * 90  // 90 days
});

2. GitHub search

Even if KV is empty (expired), check GitHub:

const searchQuery = `repo:ruzicic/fontalternatives is:issue "Font Request: Avenir"`;
const { total_count } = await searchIssues(searchQuery);
if (total_count > 0) return;  // Skip

3. Rate limiting

Max 3 issues per run prevents spam if many fonts suddenly cross the threshold.

Tradeoffs

What I gained:

  • User-informed roadmap (no guessing)
  • Automated issue creation (no manual triage)
  • Compounding effect (more fonts → more searches → more demand data)

What I lost:

  • Larger search index (7k fonts vs 400)
  • Storage for unavailable font metadata
  • Potential UX friction (“Coming soon” isn’t satisfying)

What I’d reconsider:

  • Maybe show similar available fonts when searching for unavailable ones
  • Could add email capture: “Notify me when Avenir is available”

The flywheel effect

This creates a self-improving content pipeline:

flowchart TD
    A[User searches for unavailable font] --> B[PostHog captures demand signal]
    B --> C[Worker creates GitHub issue]
    C --> D[Claude Code implements & deploys]
    D --> E[New font attracts more users]
    E --> A

    style A fill:#e3f2fd
    style E fill:#c8e6c9

Each font I add attracts users searching for similar fonts, which generates more demand signals, which drives more additions.

Cost analysis

ComponentCost
PostHog (analytics)Free tier (1M events/month)
Cloudflare Worker (cron)Free tier
Cloudflare KVFree tier
GitHub APIFree
Claude Code (per font)~$0.50

The expensive part is Claude Code implementing fonts. But at 3 fonts/day max (rate limit) and ~$0.50/font, that’s $45/month worst case.

Code snippets

PostHog query

const query = {
  kind: "TrendsQuery",
  series: [{
    kind: "EventsNode",
    event: "unavailable_font_searched",
    math: "total"
  }],
  breakdownFilter: {
    breakdown_type: "event",
    breakdown: "font_name"
  },
  dateRange: { date_from: "-30d" }
};

const response = await fetch(
  `${POSTHOG_API_URL}/api/projects/${PROJECT_ID}/query`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}` },
    body: JSON.stringify({ query })
  }
);

Fuse.js config

const fuseOptions = {
  keys: [
    { name: "name", weight: 2 },
    { name: "classification", weight: 1 },
    { name: "traits", weight: 0.8 },
    { name: "useCases", weight: 0.8 }
  ],
  threshold: 0.4,
  includeScore: true,
  ignoreLocation: true,
  minMatchCharLength: 2
};

Search event tracking

function handleSearch(query: string, results: SearchResult[]) {
  trackSearchQuery({
    query,
    results_count: results.length,
    unavailable_shown: results.filter(r => r.type === "unavailable").length
  });

  // Track strong matches on unavailable fonts
  const topResult = results[0];
  if (topResult?.type === "unavailable" && topResult.score < 0.3) {
    trackUnavailableFontSearched({
      query,
      font_name: topResult.name,
      font_slug: topResult.slug,
      classification: topResult.classification
    });
  }
}

Impact

Since implementing this system:

  • 47 GitHub issues auto-created from demand signals
  • 23 fonts added based on search demand
  • Average time from first search to deployed font: 3 days
  • Zero time spent manually reviewing “what fonts should I add”

The fonts I add now are the ones users actually want. Not guesses.

Explore on FontAlternatives

#analytics#automation#posthog#cloudflare-workers#product

More from the blog