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:
- Fuse.js fuzzy matches against all 7,000+ fonts
- Unavailable fonts appear with a “Coming soon” badge
- These results are not clickable (no page exists)
- PostHog tracks
unavailable_font_searchedevents (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
| Component | Cost |
|---|---|
| PostHog (analytics) | Free tier (1M events/month) |
| Cloudflare Worker (cron) | Free tier |
| Cloudflare KV | Free tier |
| GitHub API | Free |
| 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.