DEV Community

Cover image for How I built a 4,160-page timezone site with Next.js — programmatic SEO, a free API, and an embeddable widget
Lanre Awe
Lanre Awe

Posted on

How I built a 4,160-page timezone site with Next.js — programmatic SEO, a free API, and an embeddable widget

A couple of weeks into a remote internship led out of Finland, I kept doing the same dumb thing: showing up to calls at the wrong time. Coordinating across time zones is a tax on every remote worker, and I got tired of paying it.

So I built TimeZone Hero — a time zone converter. That part is boring. What's interesting is how it's built, because the goal wasn't "a converter." It was a converter that could actually rank on Google and pull real traffic.

Here's the thing I realized studying the incumbents: the old, ugly sites that own these search results don't win on design. They win on coverage — they have a dedicated page for every possible query ("EST to PST", "IST to London", "9am UTC to JST"). That's not a design problem. It's an architecture problem. And architecture I can do.

This is how I rebuilt that engine on Next.js 16 — and the non-obvious problems I hit along the way.

The core idea: one page per pair

The whole strategy is a single dataset. Every time zone maps a URL slug (what people actually search) to a canonical IANA id:

export interface Zone {
  slug: string;   // "est"
  abbr: string;   // "EST"
  city: string;   // "New York"
  iana: string;   // "America/New_York"
  region: string;
}

export const ZONES: Zone[] = [
  { slug: "est", abbr: "EST", city: "New York", iana: "America/New_York", region: "North America" },
  { slug: "pst", abbr: "PST", city: "Los Angeles", iana: "America/Los_Angeles", region: "North America" },
  // ...65 zones
];
Enter fullscreen mode Exit fullscreen mode

N zones generate N × (N-1) ordered pages. 65 zones → 4,160 conversion pages, all from one array. Next.js pre-renders them at build time:

// app/[pair]/page.tsx
export function generateStaticParams() {
  return allPairs().map((p) => ({ pair: `${p.from}-to-${p.to}` }));
}

export const revalidate = 86400;   // ISR: refresh daily so offsets/DST stay current
export const dynamicParams = true; // any pair not pre-built renders on demand
Enter fullscreen mode Exit fullscreen mode

The one gotcha in parsing the slug: some cities have hyphens (mexico-city, kuala-lumpur), so you can't naively split("-"). Split on the -to- delimiter instead:

function parsePair(pair: string) {
  const idx = pair.indexOf("-to-");
  if (idx === -1) return null;
  const from = getZone(pair.slice(0, idx));
  const to = getZone(pair.slice(idx + 4));
  if (!from || !to || from.slug === to.slug) return null;
  return { from, to };
}
Enter fullscreen mode Exit fullscreen mode

Each page isn't a thin doorway — it has a live converter, an hour-by-hour table, a computed "best meeting overlap" paragraph, FAQ with JSON-LD, and internal links. Templated, but substantive. That distinction matters (more on that later).

Getting time zones actually right

If you're doing timezone math with fixed UTC offsets, you have a bug — you're just waiting for a DST transition to expose it. Never hardcode offsets. Let the IANA database do it. I used Luxon:

import { DateTime } from "luxon";

export function convertSpecific(hour: number, minute: number, fromIana: string, toIana: string) {
  const from = DateTime.now().setZone(fromIana).set({ hour, minute, second: 0 });
  const to = from.setZone(toIana);
  const dayOffset = Math.round(to.startOf("day").diff(from.startOf("day"), "days").days);
  return { time: to.toFormat("h:mm a"), dayOffset };
}
Enter fullscreen mode Exit fullscreen mode

Then a subtle one bit me in production. toFormat("ZZZZ") is supposed to give you the abbreviation — "EDT", "PST". For US zones it does. For a bunch of non-US zones, Node's ICU returns a bare numeric offset instead: GMT+9 instead of JST, GMT+1 instead of BST. The times were right, but the labels looked amateur.

The fix: trust Luxon when it returns real letters, and fall back to a curated, DST-aware map only when it hands back a numeric offset:

export function zoneAbbr(dt: DateTime): string {
  const z = dt.toFormat("ZZZZ");
  if (!/\d/.test(z)) return z; // "EDT", "JST", "GMT" — already correct
  const pair = ABBR[dt.zoneName ?? ""]; // [standard, daylight]
  if (pair) return dt.isInDST ? pair[1] : pair[0];
  return z; // no mapping — keep the offset, still unambiguous
}
Enter fullscreen mode Exit fullscreen mode

dt.isInDST picks the right label in both hemispheres — London shows BST in July, Sydney correctly shows AEST (its winter). Small detail, but "3 PM EDT is 7 AM GMT+9" screams unfinished.

The embeddable widget — and the backlink myth

A free embeddable widget is a classic way to earn backlinks: other people put your tool on their site, each embed links back to you. I built a /embed route to be dropped into an iframe. Two non-obvious problems showed up.

Problem 1: chrome bleed. In the App Router, the root layout wraps every route — so my site header and footer rendered inside the iframe on other people's pages. Ugly.

The clean fix is a route group. Move the chrome into a (site) group layout, keep the root layout minimal, and put /embed outside the group:

app/
  layout.tsx            # minimal: <html><body> only
  (site)/
    layout.tsx          # header + footer live here
    page.tsx            # home
    [pair]/page.tsx     # the 4,160 pages
  embed/
    page.tsx            # renders chrome-free
Enter fullscreen mode Exit fullscreen mode

Route groups don't change URLs, so none of my 4,000+ indexed pages moved — critical when you've already got SEO equity you don't want to break.

Problem 2: an iframe's src is not a backlink. This is the one that surprised me. The entire point of the widget is links back to your domain — but Google doesn't credit an iframe's src as a link. A pure embed earns you nothing.

So the copy-paste snippet ships a visible <a> tag alongside the iframe, which the host renders in their own DOM:

<iframe src="https://www.timezonehero.com/embed?from=est&to=pst"
        width="100%" height="380" style="border:0;max-width:640px"></iframe>
<p><a href="https://www.timezonehero.com">Timezone Converter</a> by TimeZone Hero</p>
Enter fullscreen mode Exit fullscreen mode

That <a> is the backlink. The iframe is just the useful part.

A free API from the same core

The conversion logic already existed, so exposing it as an API was almost free — a Next.js route handler with CORS so it works from the browser:

// app/api/convert/route.ts
export function GET(req: NextRequest) {
  const sp = req.nextUrl.searchParams;
  const from = resolveZone(sp.get("from")); // accepts "est" OR "America/New_York"
  const to = resolveZone(sp.get("to"));
  if (!from || !to) return err("Invalid or missing zone.");
  // ...convert, then:
  return NextResponse.json(body, {
    headers: { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=60" },
  });
}
Enter fullscreen mode Exit fullscreen mode
curl "https://www.timezonehero.com/api/convert?from=est&to=pst&time=15:00"
# → { "summary": "3:00 PM EDT is 12:00 PM PDT", "offsetHours": -3, ... }
Enter fullscreen mode Exit fullscreen mode

No key, no signup. It accepts either my slugs or raw IANA ids, which makes it useful beyond my own 65 zones.

The unsexy parts that actually decide it

  • CLS-safe ads. Ad code destroys Core Web Vitals if it shifts layout, and bad CWV hurts rankings — which starves the ads. Every ad slot reserves a fixed height up front so Cumulative Layout Shift stays ~0.
  • Canonical URLs. I auto-resolve the canonical from VERCEL_PROJECT_PRODUCTION_URL at build time, so tags and sitemap are correct with zero config. One catch: my apex 308-redirects to www, so the canonical has to point at the serving domain, not the redirecting one — otherwise you're canonicalizing to a redirect.
  • A deploy that got blocked for a dumb reason. My first Vercel deploy failed because my git commit email wasn't linked to my GitHub account (Vercel's Git-author protection). Fixed by committing with my GitHub-linked identity. An hour of confusion over one line of config.
  • The discipline not to scale. It's tempting to also generate /3pm-est-to-pst pages — that's another ~50,000 URLs. But on a brand-new domain with no authority, dumping 50k pages reads as thin content and can throttle the whole site. I capped it deliberately and I'll grow in stages.

Where it is now

It's live at timezonehero.com: 4,160 pages, a free embeddable widget, and a free API. Indexed, early, and growing.

Takeaways if you're building something similar:

  • Programmatic SEO is an architecture problem — one dataset, generateStaticParams, substantive templated pages.
  • Never do offset math; use the IANA database, and watch out for ICU returning GMT±n instead of abbreviations.
  • Route groups let you carve out chrome-free routes (like an embed) without touching your URLs.
  • An iframe src isn't a backlink — ship a real <a> in the snippet.
  • On a young domain, restraint beats volume. Prove it ranks before you 10× the page count.

If you've shipped an embeddable widget, I'm curious how you handled attribution — script injection, iframe + link, or something better? And if you want to poke at the API, it's open — no key needed.

Top comments (0)