DEV Community

Citation Builder
Citation Builder

Posted on

Which Business Directories Matter in Each Country? Comparing Markets From One Edge-Cached Endpoint

If you build anything for local businesses, sooner or later someone asks which directories they should be listed on. The honest answer is "it depends on the country and the industry", and that answer is only useful if you can put numbers on it. This post shows a 40-line Node.js script that compares five markets through one public JSON endpoint, the output it produced on October 20, 2026, and two things we learned building the endpoint: why the raw ranking needed a relevance filter, and how edge caching keeps a free tool cheap.

The endpoint

The data comes from the catalog behind our free local citation finder: about 1,280 active directories, each mapped to the countries and industries it serves. The tool calls one endpoint:

GET https://localseocitationbuilder.com/api/citation-finder?country=DE&industry=dentists
Enter fullscreen mode Exit fullscreen mode

industry is optional. The response carries the totals for the whole ranked list (total, free, buildable and a byKind split into national, global and industry sources) plus the top 15 rows, each with a name, host, cost model and a kind label. Country codes are ISO 3166 alpha-2 and industry slugs are the ones used in our public URLs; anything else gets a 400 or a 404.

The script

No dependencies, Node 20 or newer for the global fetch:

// Compare the citation-site landscape of several markets with one public, edge-cached endpoint.
// Node 20+, no dependencies:  node compare-markets.mjs
const BASE = 'https://localseocitationbuilder.com/api/citation-finder';

async function finder(country, industry) {
  const qs = new URLSearchParams({ country });
  if (industry) qs.set('industry', industry);
  const res = await fetch(`${BASE}?${qs}`);
  if (!res.ok) throw new Error(`${country}/${industry ?? '-'}: HTTP ${res.status}`);
  return { body: await res.json(), cache: res.headers.get('x-vercel-cache') ?? 'n/a' };
}

const pairs = [['DE'], ['IN'], ['AE'], ['US', 'health-medical'], ['US', 'dentists']];
const rows = [];
for (const [country, industry] of pairs) {
  const { body, cache } = await finder(country, industry);
  const national = body.sites.filter((s) => s.kind === 'national').map((s) => s.name);
  rows.push({
    market: `${body.country.name}${body.industry ? ` / ${body.industry.name}` : ''}`,
    total: body.total,
    national: body.byKind.national,
    global: body.byKind.global,
    niche: body.byKind.industry,
    freePct: Math.round((100 * body.free) / body.total),
    topNational: national.slice(0, 3).join(', ') || '(none in the top 15)',
    cache,
  });
}
console.table(rows);
Enter fullscreen mode Exit fullscreen mode

What it printed

This run had a warm cache; it is pasted as it came out:

┌─────────┬────────────────────────────────────┬───────┬──────────┬────────┬───────┬─────────┬───────────────────────────────────────────────┬───────┐
│ (index) │ market                             │ total │ national │ global │ niche │ freePct │ topNational                                   │ cache │
├─────────┼────────────────────────────────────┼───────┼──────────┼────────┼───────┼─────────┼───────────────────────────────────────────────┼───────┤
│ 0       │ 'Germany'                          │ 48    │ 21       │ 27     │ 0     │ 69      │ 'Gelbe Seiten, Das Örtliche, Das Telefonbuch' │ 'HIT' │
│ 1       │ 'India'                            │ 34    │ 7        │ 27     │ 0     │ 82      │ 'Justdial, Sulekha, UrbanPro'                 │ 'HIT' │
│ 2       │ 'United Arab Emirates'             │ 30    │ 3        │ 27     │ 0     │ 90      │ 'HiDubai, Business Network UAE'               │ 'HIT' │
│ 3       │ 'United States / Health & Medical' │ 93    │ 8        │ 17     │ 68    │ 85      │ 'WebMD, AAO, Healthgrades'                    │ 'HIT' │
│ 4       │ 'United States / Dentists'         │ 88    │ 6        │ 17     │ 65    │ 85      │ 'Healthgrades, Nextdoor, BBB'                 │ 'HIT' │
└─────────┴────────────────────────────────────┴───────┴──────────┴────────┴───────┴─────────┴───────────────────────────────────────────────┴───────┘
Enter fullscreen mode Exit fullscreen mode

A few things stand out.

The national layer is what separates markets. Every market shares the same 27 global sources (map and place databases, the largest general directories, the social platforms), so the difference is entirely national. Germany has 21 national directories, led by Gelbe Seiten, Das Örtliche and Das Telefonbuch. India has 7, led by Justdial and Sulekha. The United Arab Emirates has 3. A business in Dubai and a business in Munich need very different amounts of work.

An industry changes the list more than a country does. Adding an industry pulls in its niche sources from every market: 68 for health and medical, 65 for dentists. For a US dentist the niche layer is bigger than the national and global layers combined.

Free is the norm. Between 69% (Germany, where several national directories sell paid tiers) and 90% (UAE) of the sources are free to list on.

Lesson 1: a country mapping is not a relevance signal

The first version of the endpoint returned the raw ranking, and "United States + Dentists" came back with LII, Justia and FindLaw (legal directories), Zillow (real estate) and Hostelworld (hostels) in its top 20. Nothing was wrong with the data: those sites really are important US directories, mapped to the US as a country. The bug was treating "mapped to the country" as "relevant to every business in the country".

The fix is one rule. When an industry is selected, a directory survives only if it has no industry mapping at all (a true general directory) or is mapped to that industry:

const sites = ranked.filter((s) => {
  const industries = byDirectory.get(s.id); // Set of industry slugs, or undefined
  return !industries || industries.has(selectedIndustry);
});
Enter fullscreen mode Exit fullscreen mode

For US dentists that removed 44 off-topic rows and took the list from 132 to 88. The number got smaller and the answer got better, which is the right direction for a tool people act on.

Lesson 2: cache by the question, not by the visitor

Every answer is a pure function of (country, industry), and there are only a few thousand pairs. So the response is cacheable at the edge for a day, with stale-while-revalidate for a week. That is what the cache column shows: on a cold cache each pair prints MISS on its first request, and on the run above every row is a HIT. A free tool that hits the database once per pair per day costs almost nothing, no matter how many people use it.

The same reasoning applies to the relevance map in Lesson 1: the directory-to-industry table changes rarely, so the server loads it once an hour instead of joining it on every request.

Using the output

The list answers "where should this business be listed?". It does not tell you where it already is, or whether the existing listings agree on the name, address and phone. For that you need a citation audit; the useful order is: fix the canonical name, address and phone, search each directory for an existing record and claim it, then add the missing ones, starting with the global sources because their data travels furthest.

If you want the ranked lists without writing code, they are on the local citation finder and on the country and industry pages linked above.

Top comments (0)