, , , ,

981 radio stations, 22 countries, one plugin

Radio Nations is a live radio directory we built and maintain. The work that would normally be six or seven plugins lives in the theme instead, so the live site runs one, and it only sends email.

Client
Radio Nations, live radio directory
Sector
Publishing and streaming
Timeline
Built 2026, monthly care since
Scope
WordPress 7.1, PHP 8.3, custom theme, nginx, CloudPanel, Cloudflare
1 pluginActive on the live site, and it only sends email
0.035sTime to first byte, measured from Dhaka
0ms / 0Total Blocking Time and Cumulative Layout Shift

Radio Nations is a live radio directory. Visitors pick a country, pick a station and listen in the browser. It runs on WordPress, and we built and maintain it.

The number that surprises people is the plugin count. A site this size normally carries an SEO plugin, a multilingual plugin, a caching plugin, a security plugin, a reviews plugin and a REST API plugin. This one runs one, and it only sends transactional email.

Measured on the live site
Published station pages981
Countries22
Pages indexed by Google861, up from 106 on 1 June
Active plugins1
Homepage weight0.30 MB across 15 requests
Time to first byte0.035 to 0.045 s
Total Blocking Time0 ms
Cumulative Layout Shift0

What we built

A custom theme, liveradio-theme, currently at version 1.12.26. Roughly 19,000 lines: 11,909 across 23 PHP modules, 2,997 in templates, 2,610 lines of CSS and 1,647 of JavaScript.

The modules are the product rather than a skin over other people’s plugins.

ModuleWhat it replaces
seo.php, schema.php, sitemap.php, hreflang.php, redirects.php, ai-discovery.phpan SEO plugin
i18n.php, genre-i18n.php, flags.phpa multilingual plugin
performance.phpa caching plugin
security.phpa hardening plugin
reviews.phpa reviews plugin
app-api.phpa REST API plugin
post-types.php, taxonomies.php, meta.phpa custom-fields plugin
Bar chart of the twelve largest PHP modules in the custom theme, from i18n at 1339 lines down to security at 540
The twelve largest modules. Each covers work a WordPress site normally installs a plugin for.

Every one of those is code we own, that loads only what the page needs, and that cannot break when somebody else ships an update.

The front end carries a persistent audio player, a live listener count, favourites tied to real user accounts, star reviews, station search, a country switcher and a consent banner that actually gates the analytics.

The multilingual engine

Station pages are written in the language of the country they belong to. A Polish station reads in Polish, a German one in German.

Behind that: an 86-entry locale registry, six compiled UI translations (en_US, en_GB, de_DE, es_ES, fr_FR, pt_BR), 23 hreflang tags on the homepage and every country archive, and an <html lang> that changes per country. Station pages carry no hreflang, which is correct rather than missing: a station belongs to one country, so it has no alternates to point at.

Structured data

A station page emits RadioStation, WebSite, WebPage, FAQPage, BreadcrumbList, ListenAction, SpeakableSpecification, SearchAction and ImageObject as JSON-LD. The FAQ blocks are real questions with real answers, not padding.

Speed

Time to first byte measured from Dhaka is 0.035 to 0.045 seconds. That is edge delivery, not a fast origin.

The theme sends cache-control: public, max-age=300, s-maxage=14400, stale-while-revalidate=86400 plus its own x-lr-edge: eligible marker, and a Cloudflare cache rule reads them. HTML is cached at the edge for anonymous visitors, revalidated in the background, and bypassed for anyone with a WordPress cookie. Cache purges are automatic when content changes.

The rest is weight the page never picks up:

  • The homepage transfers 0.30 MB across 15 requests.
  • theme.min.css is 51.7 KB, app.min.js is 30.9 KB.
  • gtag.js is not downloaded until the first user interaction, or five seconds, whichever comes first. The inline gtag('config', …) still runs immediately, and because gtag calls only queue into dataLayer, the library replays the queue when it arrives. Nothing is lost except bounces shorter than five seconds.

The result is Total Blocking Time of 0 ms and Cumulative Layout Shift of 0. There is no main-thread work to block on and nothing moves after paint.

SEO, including the part that has not worked yet

The technical side is done and verifiable: canonical tags, hreflang clusters, a multilingual sitemap index, JSON-LD on every template, breadcrumbs, redirect handling for legacy country codes, and a discovery layer for AI crawlers.

Indexed pages went from 106 on 1 June to 861 on 21 August. Google crawls the site mostly as Smartphone Googlebot, and 68% of its requests are HTML.

Line chart showing pages indexed by Google rising from 106 in June to 861 in August 2026
Google Search Console, indexed pages, 1 June to 21 August 2026.

Impressions followed. Comparing the first fourteen days of the measured period with the last fourteen: 1,254 to 2,171, about 73% more.

Clicks are still small: 66 across 87 days. Average position also moved the wrong way, from 49.5 to 66.3.

That second number is worth explaining rather than hiding. When a site adds roughly 750 pages in three months, most of them new and unlinked from anywhere, the average position across all queries falls even when nothing has got worse. The site is ranking for far more things, most of them deep. Impressions rising while position falls is the signature of that, not of a penalty.

What it means practically: indexing and crawlability are solved, and earning clicks is the open problem. That is a content and link-building job, not a technical one.

Security

Verified live on the day this was written.

Working:

  • xmlrpc.php returns 403
  • /wp-json/wp/v2/users returns 401
  • ?author=1 enumeration redirects to the homepage
  • HSTS for one year, includeSubDomains, preload
  • Content-Security-Policy on frame-ancestors, base-uri, object-src, form-action
  • X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff
  • Visitor IP is only trusted from a real Cloudflare peer, so the login rate limit cannot be bypassed with a spoofed header
  • Prepared statements, nonces on every AJAX endpoint, honeypots on forms

Still open, and tracked:

  • wp-login.php is directly reachable. A custom /rn-login/ route exists but the block is inactive until the nginx rewrite and an option flag are set.
  • readme.html returns 200, which leaks the WordPress version.
  • /register/ loads publicly.
  • The origin IP answers on 443 directly, so the Cloudflare WAF can be bypassed by anyone who knows the address. The fix is a firewall rule limiting 80 and 443 to Cloudflare ranges.

None of those are code defects. They are server and settings work, they are on the list, and publishing them here is more useful than claiming a clean sheet.

What monthly maintenance actually looks like

WordPress 7.1 on PHP 8.3. The database is backed up every night at 03:15 and the most recent backup is from this morning.

The theme has shipped around fifty releases between May and August 2026, from version 1.6.2 to 1.12.26. Small changes, shipped often, each one versioned so it can be rolled back on its own.

Still open

Beyond the four security items above:

  • Clicks. The technical foundation is in place; the traffic is not.
  • Two WordPress.org plugins, extracting the player and the station directory from the theme, are at idea stage and have not been built.
  • Cloudflare Tiered Caching is available on the current plan and is not switched on yet, which would raise the global cache hit ratio.

Scope: custom theme build plus ongoing monthly maintenance. Stack: WordPress, PHP 8.3, custom theme, nginx, CloudPanel, Cloudflare.

More of the work

See every case study β†’

Your site next

Want the same read on your site?

Send the address and one line about what worries you. We write back with what is broken and what each fix costs.

Free, and read only. No card, no contract.

Chat on WhatsApp