It started with a dumb frustration. Every "how long to cook chicken thighs" page gives you a different number, and none of them tell you where the number comes from. Some say 25 minutes, some say 40, and you're standing there with a thermometer wondering who to trust.
So I did the developer thing and over-engineered a solution. What began as one cooking-time calculator turned into ~1,800 generated pages. Here's how it's built and a couple of footguns that cost me real traffic.
The data model: start from a source, not from blog numbers
The core idea: don't hardcode "chicken = 30 min". Start from the official safe internal temperature (USDA/FSIS charts — poultry 74°C, whole cuts 63°C + rest, fish 63°C) and treat time as a function of geometry.
Each food is a row with a reference point and a driver:
type CookingEntry = {
slug: string;
safeMinInternalTempC: number | null;
referenceTempC: number;
referenceTimeMin: number;
referenceTimeMax: number;
referenceThicknessCm: number | null;
referenceWeightG: number | null;
keyGeometryDriver: "thickness" | "total_weight";
};
Then the estimate scales the reference time by how far your piece is from the reference, using a power law instead of a linear guess (a steak twice as thick doesn't take twice as long — heat diffusion is closer to thickness²):
const ratio = input.thicknessCm / entry.referenceThicknessCm;
const factor = Math.pow(ratio, 1.2); // clamped to sane bounds
const minutes = referenceCenter * factor * tempFactor;
Is it perfect? No — it's a household estimate, and the page says so and shows the safe temperature so you can verify with a thermometer. But it's honest about where it comes from, which is more than most results give you.
1 entity = 1 indexable page
The whole thing is programmatic SEO: every food, every city, every scenario is its own route under the Next.js App Router.
app/[locale]/cooking/how-long-to-cook/[slug]/page.tsx
app/[locale]/date-time/world-clock/[slug]/page.tsx
Metadata is generated per-entity in generateMetadata, and there's a dynamic app/sitemap.ts that enumerates every dataset entry. The trap here is lastmod: I first set it to new Date(), which re-stamped all ~1,800 URLs as "changed" on every deploy. Crawlers learn to distrust that fast. Fix: a single datasetLastReviewed constant I bump only when the data actually changes.
SQLite is fine (really)
The datastore is SQLite via Prisma. For a read-heavy site with curated datasets it's genuinely fine — no connection pool, no separate DB server, the whole thing is a file you can .backup in a cron job. The dataset itself lives in versioned TSV/JSON, so the DB is more of a cache than a source of truth.
Time zones will humble you
The world-clock pages use the Temporal API (via the polyfill) instead of raw Date, because DST math is where naive code dies:
const zoned = instant.toZonedDateTimeISO("Europe/Prague");
const offsetMin = Math.round(zoned.offsetNanoseconds / 60_000_000_000);
Each page also computes the next clock change by binary-searching for the offset flip, so a "12:00 UTC to Kyiv" page can tell you when the answer will shift. Real IANA zones, date-aware offsets, no static UTC+2 lies.
Two footguns that cost me traffic
-
wwwreturning 200. Behind a proxy,request.nextUrl.hostnameisn't reliable — I had to checkHostandX-Forwarded-Hostin middleware to 301www→ apex. Until I did, Google had two copies of every page and split the signals. -
hreflangx-defaultpointing at the wrong locale. It decides what Google serves everyone outside your declared locales. Mine defaulted to the non-English version while the inventory targeted English queries. One-line change, big difference.
Stack, for the curious
Next.js (App Router) + TypeScript + Tailwind + Prisma/SQLite, ~1,800 pages generated from curated datasets, deployed with a boring git pull + pm2 restart script. Solo project.
If you want to poke at the actual output, it's live and free (no signups, no paywall): https://theunitools.com/en — I'd genuinely like to hear if a cooking time looks off to you, since that's the part I care about most.
Top comments (0)