A Next.js Calendar Grid, and the Timezone Bug It Has to Avoid
No calendar UI here, but this codebase already fixed the timezone bug that breaks most of them, twice. The UTC-safe date pattern, applied to a month grid.
A calendar grid is a date-arithmetic problem before it's a UI problem, and the arithmetic bug that breaks more calendars than any other is a single missing Z: parsing a plain "2026-08-28" string with new Date() reads it in the browser's local timezone, not UTC, so a visitor west of Greenwich can see every date shifted back by one day. This codebase doesn't ship a calendar UI, but it has already hit — and fixed — exactly this bug twice, in code that has nothing to do with calendars.
The three date bugs a calendar grid will hit
| Bug | Symptom | Where this codebase already solved it |
|---|---|---|
| Local-timezone parsing of a date-only string | A date renders one day off for visitors west of UTC | T00:00:00Z appended before every new Date() call on a stored date |
Comparing a build-time new Date() against a stored day | Equality checks pass or fail depending on the exact second the code ran | A day is stored and compared as a declared T00:00:00Z constant, never new Date() at read time |
| Month-length and leap-year arithmetic done by hand | February renders 5 weeks some years, 4 others, off by one on leap years | Delegated to Date's own overflow behavior — new Date(year, month + 1, 0) — rather than a hand-written days-in-month table |
The pattern, read from a file that isn't a calendar
src/components/organisms/ChangelogList.tsx renders every template release with a human-readable date, and gets the timezone right on purpose:
// src/components/organisms/ChangelogList.tsx
/** Deterministic "Jul 1, 2026" from an ISO yyyy-mm-dd date (UTC, en-US). */
function formatDate(iso: string): string {
return new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-US", {
timeZone: "UTC",
year: "numeric",
month: "short",
day: "numeric",
});
}
Two things are doing the work: the T00:00:00Z suffix forces the parse into UTC instead of the runtime's local zone, and timeZone: "UTC" forces the format step to read it back in UTC too — both ends of the round trip have to agree, or a correct UTC Date object still renders in the server or browser's local zone at the formatting step. Drop either half and a release dated "2026-07-01" can print as June 30th for part of the world.
This isn't a one-off. src/app/sitemap.ts declares a build-independent revision date the same way —
// src/app/sitemap.ts
export const STOREFRONT_COPY_REVISED = new Date("2026-08-21T00:00:00Z");
— specifically so it isn't new Date() at build time, which the file's own comment calls out as "the build-clock lie the rule exists to prevent." A sitemap.xml lastModified needs to compare as a real day regardless of what second the build ran, which is the same requirement a calendar's "is this cell today" check has. The invariant is enforced by a real test, not just a comment:
// src/data/__tests__/internalLinking.test.ts
it("every product and hub date is a declared day, not a clock reading", () => {
// The real guard against a build-clock regression: a declared date is
// midnight UTC, a `new Date()` at build time never is.
for (const entry of tiered) {
const iso = new Date(entry.lastModified!).toISOString();
expect(iso.endsWith("T00:00:00.000Z"), `${entry.url} → ${iso}`).toBe(true);
}
});
A calendar grid needs the same guarantee for a different reason: every cell has to compare as the same day regardless of what time the page rendered, or "today" highlights the wrong cell for part of your audience.
A minimal month grid, written for this article
This site has no calendar UI to audit, so the following is written for this post rather than pulled from the codebase — it applies the pattern above to the part that's actually reusable, the grid math:
"use client";
import { useMemo, useState } from "react";
function startOfMonthUTC(year: number, month: number) {
return new Date(Date.UTC(year, month, 1));
}
function daysInMonthUTC(year: number, month: number) {
// Day 0 of next month overflows back to the last day of this one —
// no hand-written days-in-month table, no leap-year special case.
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
}
export default function MonthGrid({ year, month }: { year: number; month: number }) {
const [today] = useState(() => new Date());
const cells = useMemo(() => {
const first = startOfMonthUTC(year, month);
const leadingBlanks = first.getUTCDay(); // 0 = Sunday
const total = daysInMonthUTC(year, month);
return [
...Array.from({ length: leadingBlanks }, () => null),
...Array.from({ length: total }, (_, i) => i + 1),
];
}, [year, month]);
return (
<div className="grid grid-cols-7 gap-1">
{cells.map((day, i) =>
day === null ? (
<div key={i} />
) : (
<time
key={i}
dateTime={`${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`}
className="flex h-10 items-center justify-center rounded-lg text-sm text-title-color dark:text-white/90"
>
{day}
</time>
),
)}
</div>
);
}
Date.UTC(...) and getUTCDay()/getUTCDate() keep every calculation in UTC from construction to read-back — the same discipline formatDate applies to a single date, extended to a grid of them. The <time dateTime> element is the one piece already established elsewhere in this codebase's real markup (ChangelogList.tsx uses the same element for its release dates), which is also what gives a screen reader and a search crawler an unambiguous machine-readable date independent of how the cell is styled.
Library vs. hand-rolled
| A calendar library (FullCalendar, DayPilot, react-big-calendar) | The grid above | |
|---|---|---|
| Month/week/day views, drag-to-reschedule, recurring events | Included | You build only what you need |
| Bundle cost | A real dependency, often 50KB+ gzipped | Zero — it's ~30 lines of Date arithmetic |
| Timezone correctness | The library's responsibility | Yours — get the UTC discipline above wrong and every date is off by one somewhere |
| Right choice when | You need scheduling: drag-drop, recurring events, multi-resource views | You need a date picker or an events-per-day display, which is most admin dashboards |
Most "admin dashboard" calendars are the second case — a month grid with a dot or count per day, not a scheduling tool — which is exactly where reaching for a full calendar library costs more than it returns.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
new Date("2026-08-28") with no time or zone | Renders as the 27th for visitors west of UTC | Append T00:00:00Z, or build with Date.UTC(...) from the start |
| Formatting with the runtime's local zone | A UTC-correct Date object still prints the wrong day | Pass timeZone: "UTC" to toLocaleDateString/Intl.DateTimeFormat too — both ends of the round trip must agree |
| Hand-written days-in-month tables | Breaks on leap years, or needs an annual update | Use new Date(Date.UTC(y, m + 1, 0)).getUTCDate() — overflow does the arithmetic |
Comparing new Date() at render time against a stored day | "Today" highlights the wrong cell depending on when the page rendered vs. the visitor's clock | Compare declared UTC days, per the invariant test above |
| Reaching for a scheduling library to show an events-per-day dot | Ships 50KB+ for a feature the grid above covers in 30 lines | Match the library to what you're actually building — see the table above |
Frequently asked questions
Why does T00:00:00Z matter if the server and browser are both set to UTC anyway?
They usually aren't. A visitor's browser reads its own system timezone regardless of where the server rendered the page, so a date parsed without an explicit zone is only "correct" for visitors who happen to share the server's offset — which for a public site is nobody you can rely on.
Is Date.UTC() the same as just using new Date() and ignoring the timezone?
No — new Date(year, month, day) (no UTC) constructs the date in the local timezone of wherever the code runs, which is the exact bug this whole pattern avoids. Date.UTC(...) returns a timestamp, which you then wrap in new Date(...), entirely independent of local time.
When is a calendar library worth the bundle cost? When you need what a hand-rolled grid doesn't cover cheaply: drag-to-reschedule, recurring events, multi-resource/multi-calendar views, or timezone-aware scheduling across attendees. An events-per-day display or a date picker rarely needs any of that.
Does this affect sitemap.xml dates the same way it affects a calendar UI?
Yes — it's the same underlying requirement (a day that compares consistently regardless of when or where the code runs), which is why STOREFRONT_COPY_REVISED and the changelog dates use the identical T00:00:00Z discipline shown above, enforced by the invariant test quoted earlier.
Templates in this post
ASoc Scholar Admin ships a calendar app as one of its workspace modules (alongside inbox, kanban board and an invoice builder) across 13 dashboards and 210+ pages. ASoc Apex Admin also ships a calendar app in its workspace suite, next to email, chat and kanban, across 5 dashboards and 115+ pages. ASoc Vertex Admin is a single, densely-built eCommerce dashboard view with a full sidebar shell that includes a calendar-adjacent nav structure (Projects, Tasks, Invoices) — today only the eCommerce view itself carries real data, which the product page states plainly.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the loading-state half of an admin UI, React skeleton loaders; for the accessibility pattern behind an interactive grid like this one, the accessible mega-menu post.
