Skip to main content
ASoc
Tutorial

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.

The ASoc Team7 min read

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

BugSymptomWhere this codebase already solved it
Local-timezone parsing of a date-only stringA date renders one day off for visitors west of UTCT00:00:00Z appended before every new Date() call on a stored date
Comparing a build-time new Date() against a stored dayEquality checks pass or fail depending on the exact second the code ranA 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 handFebruary renders 5 weeks some years, 4 others, off by one on leap yearsDelegated 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 eventsIncludedYou build only what you need
Bundle costA real dependency, often 50KB+ gzippedZero — it's ~30 lines of Date arithmetic
Timezone correctnessThe library's responsibilityYours — get the UTC discipline above wrong and every date is off by one somewhere
Right choice whenYou need scheduling: drag-drop, recurring events, multi-resource viewsYou 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

MistakeSymptomFix
new Date("2026-08-28") with no time or zoneRenders as the 27th for visitors west of UTCAppend T00:00:00Z, or build with Date.UTC(...) from the start
Formatting with the runtime's local zoneA UTC-correct Date object still prints the wrong dayPass timeZone: "UTC" to toLocaleDateString/Intl.DateTimeFormat too — both ends of the round trip must agree
Hand-written days-in-month tablesBreaks on leap years, or needs an annual updateUse 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 clockCompare declared UTC days, per the invariant test above
Reaching for a scheduling library to show an events-per-day dotShips 50KB+ for a feature the grid above covers in 30 linesMatch 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.

Keep reading

Tutorial8 min read

Next.js Charts: The Bill Isn't the Library, It's the Boundary

Every chart library is a client component. What that actually costs, measured here: 42 KB gzipped across twelve routes, from a five-line constant nobody suspected.

Read more
Tutorial7 min read

A Next.js CI Pipeline in 23 Lines, Timed Step by Step

Real per-step timing from a production run — 100 seconds total, cheapest checks first, no separate typecheck step because next build already does one.

Read more
Tutorial11 min read

A Next.js Contact Form with Server Actions, Zod and Resend

No API route, no client fetch, and it still submits with JavaScript off. Validation, a honeypot, a rate limit that survives serverless, and the from-address trap that kills deliverability.

Read more