Blog dates off by one day: the timezone bug that affects every Astro site
The post title said "Published: July 10" but the page showed "July 9." The Astro dev server ran in UTC-3, and new Date("2026-07-10T00:00:00Z") midnight UTC becomes July 9 at 9 PM in Brazil. The fix looked like one line, but the problem had three layers.
The bug surfaced during a visual review of the blog. A post published on July 10 appeared as July 9 on the page. I checked another post. Same problem. All 46 posts on the site were off by one day.
The cause: new Date with UTC string vs server timezone
Each post frontmatter had a simple date:
publishedAt: "2026-07-10"
The formatting code did:
const dateFormatter = new Intl.DateTimeFormat("en-US", {
day: "numeric",
month: "short",
year: "numeric",
});
const publishedDate = dateFormatter.format(new Date(`${entry.data.publishedAt}T00:00:00Z`));
The string "2026-07-10T00:00:00Z" creates a Date object at midnight UTC. Intl.DateTimeFormat without timeZone uses the timezone of the environment where the code runs. In local development (macOS in UTC-3), midnight UTC becomes 9 PM the previous day. The result: July 9 on screen, July 10 in the frontmatter.
In production on Cloudflare Pages, the build runs in UTC, so the bug never appeared in deployed output. But anyone running pnpm sites:dev in Brazil saw wrong dates.
First fix: timeZone: "UTC"
The immediate correction was adding timeZone: "UTC" to each Intl.DateTimeFormat:
const dateFormatter = new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
year: "numeric",
timeZone: "UTC",
});
Three components had the formatter: BlogPostPage.astro, BlogIndexPage.astro, and LandingPage.astro. All in the same site (sunstoneapps.com). The other two sites in the monorepo have no blog, so they needed no changes.
The bigger problem: sorting by localeCompare
With the timezone fix, dates displayed correctly. But there was a structural issue worth solving in the same pass.
Post sorting used localeCompare on date strings:
.sort((left, right) =>
right.data.publishedAt.localeCompare(left.data.publishedAt),
);
This works for dates in YYYY-MM-DD format because lexicographic order matches chronological order. But it breaks in two scenarios:
- If two dates use different timezone suffixes (
"2026-07-10T00:00:00Z"vs"2026-07-10T00:00:00-03:00"), string comparison does not reflect real chronological order. - It cannot sort posts published on the same day — they all tie in
localeCompare.
Migration to ISO 8601 with timestamp
The solution was converting all 46 frontmatters to ISO 8601 with timestamp:
publishedAt: "2026-07-10T00:00:00Z"
And switching the sort to use Date.getTime():
.sort(
(left, right) =>
new Date(right.data.publishedAt).getTime() -
new Date(left.data.publishedAt).getTime(),
)
Now, to order two posts from the same day, just adjust the timestamp: T08:00:00Z appears before T12:00:00Z.
The concatenations that broke
The code had five places that concatenated T00:00:00Z onto the frontmatter date before passing it to new Date():
new Date(`${post.data.publishedAt}T00:00:00Z`);
After the ISO migration, this produced "2026-07-10T00:00:00ZT00:00:00Z" — an invalid string that results in Invalid Date. All five concatenations were removed, using new Date(date) directly.
The RSS feed also used the concatenation to generate <pubDate>. Same fix: new Date(post.data.publishedAt).toUTCString().
The canonical post ID
The monorepo has a canonicalBlogEntryId function that builds the post ID from the date and slug:
function canonicalBlogEntryId(entry: BlogPost): string {
return `${entry.data.publishedAt}-${entry.data.slug}.${entry.data.locale}`;
}
The file ID generated by Astro is 2026-07-10-slug.pt-BR (date only, no timestamp). After migration, publishedAt became "2026-07-10T00:00:00Z", and the built ID would be 2026-07-10T00:00:00Z-slug.pt-BR — not matching the file ID.
The fix was extracting just the date part:
function canonicalBlogEntryId(entry: BlogPost): string {
const datePart = entry.data.publishedAt.split("T")[0];
return `${datePart}-${entry.data.slug}.${entry.data.locale}`;
}
The filename continues using only YYYY-MM-DD, as before.
Schema validation
The Zod schema for the blog collection was updated from z.string() to z.string().datetime():
publishedAt: z.string().datetime(),
updatedAt: z.string().datetime().optional(),
If someone types "2026-07-10" without a timestamp, Astro now rejects it at content collection validation instead of silently producing wrong dates.
What became a rule
Three principles were documented in the monorepo’s AGENTS.md:
- Blog frontmatter uses ISO 8601 with timestamp (
"2026-07-11T00:00:00Z"), validated byz.string().datetime(). - The filename uses only the date part (
2026-07-11-slug.md). - Formatting uses
Intl.DateTimeFormat(locale, { timeZone: "UTC", ... })andnew Date(publishedAt)— never concatenateT00:00:00Z. - Sorting uses
new Date(a).getTime() - new Date(b).getTime(), notlocaleCompare.
The rule covers the scenario that motivated the original bug (server timezone differing from UTC) and the scenario that motivated the full migration (need to sort posts within the same day). Any developer, in any timezone, sees correct dates.
This bug also has an SEO side: the article:published_time in JSON-LD and OpenGraph meta tags used the same publishedAt, so Google was reading a different date than intended. For the full checklist on indexing and metadata for Astro sites on Cloudflare Pages, see Google indexing checklist for Astro sites on Cloudflare Pages.
Practical summary
| Before | After |
|---|---|
publishedAt: "2026-07-10" |
publishedAt: "2026-07-10T00:00:00Z" |
new Date(\${date}T00:00:00Z`)` |
new Date(date) |
localeCompare for sorting |
Date.getTime() for sorting |
z.string() in schema |
z.string().datetime() in schema |
No timeZone in formatter |
timeZone: "UTC" in formatter |
The bug existed since the first blog post. It was never detected in production because Cloudflare builds in UTC. But anyone running the site locally in Brazil saw wrong dates — and that includes the entire team.