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
];
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
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 };
}
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 };
}
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
}
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
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>
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" },
});
}
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, ... }
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_URLat build time, so tags and sitemap are correct with zero config. One catch: my apex 308-redirects towww, 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-pstpages — 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±ninstead of abbreviations. - Route groups let you carve out chrome-free routes (like an embed) without touching your URLs.
- An iframe
srcisn'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)