Timezone Bugs in a Static Site

web development astro

A week after migrating this site to Astro, I clicked an old link and got an S3 XML error. The URL was /2016/11/06/ditched-blogofile-for-hugo/. The post existed. The content was there. But Astro had built it at /2016/11/07/ditched-blogofile-for-hugo/. One day off.

It took me a minute to understand what happened. Then I checked the frontmatter:

date: 2016-11-06T21:49:37-08:00

November 6th, 9:49 PM Pacific. But in UTC, that’s November 7th, 5:49 AM. And JavaScript’s Date.getUTCDate() returns 7.

How Hugo Does Dates

Hugo is written in Go, and Go’s time.Time preserves the original timezone from the parsed string. When Hugo’s permalink template says /:year/:month/:day/:title/, it uses the date as written. November 6th at 9 PM Pacific is November 6th. The timezone offset is metadata, not a conversion trigger.

JavaScript doesn’t work this way. When you create a Date from an ISO string, it converts everything to UTC internally. The original timezone is discarded. If you want the year, month, and day, you have to choose: getFullYear() gives you the date in the runtime’s local timezone, and getUTCFullYear() gives you UTC. Neither gives you the date as the author wrote it.

My Astro code was using the UTC methods, which seemed like the safe choice. Timezone-independent. Deterministic. Wrong.

43 Out of 152

I wrote a script to check every post. Forty-three of my 152 blog posts were written late enough in the evening Pacific time to roll over to the next day in UTC. Every single one of those posts had a different URL than what Hugo had generated. Twenty years of links, all quietly broken.

The irony of getting bitten by a timezone bug on a static site with no server, no database, and no user input is not lost on me.

The Fix

The solution is almost embarrassingly simple. Don’t use the Date object for URL generation at all. Instead, read the raw frontmatter string and parse the date components directly:

// src/utils/dates.ts
const content = readFileSync(filePath, 'utf8');
const match = content.match(/^date:\s*["']?(\d{4})-(\d{2})-(\d{2})/m);

The date 2016-11-06T21:49:37-08:00 starts with 2016-11-06. That’s the date the author intended. No timezone conversion, no UTC normalization, no surprises. It matches what Hugo did, and it matches what a human reading the frontmatter would expect.

Astro’s YAML parser converts date strings to JavaScript Date objects before your code ever sees them, so the raw string is gone by the time you’re building URLs. The utility reads the markdown files directly at build time, parses the date from the raw text, and caches the results. It’s a workaround for a leaky abstraction, but it’s a correct workaround.

Lessons, If You Want Them

The real lesson is one I already knew and ignored: timezone handling is where confident assumptions go to die. I chose getUTCDate() because it felt rigorous. UTC is the canonical timezone. UTC is deterministic. UTC is also not what Hugo used, and URL compatibility was the entire point of the migration.

The secondary lesson is that date types that discard timezone information are dangerous. JavaScript’s Date stores an instant in time, not a calendar date. Those are different things. “November 6th in Pacific time” is a calendar date. “2016-11-07T05:49:37Z” is an instant. If you need the calendar date, don’t round-trip through an instant.

The third lesson is to click your own links after a migration. I should have caught this a week ago.

Discussion