Moltenship Time
Guides

Next.js and server rendering

Fetch events in a Server Component and render the same calendar on the server and in the browser.

Time works with any React 19 framework that renders on the server. This guide uses the Next.js App Router. The same rules apply elsewhere.

Calendars are Client Components

Calendar state lives in React state, so createCalendar, defineCalendarView, and the components are client-only. Put your calendar, its views, and Root in files that start with "use client", as the Quick start does. Server Components can render those components and pass them props.

The rest of @moltenship/calendar works on the server too. A Server Component can import the Temporal helpers or build a snapshot, for example to filter events before sending them.

Send strings, not Temporal objects

Props from a Server Component to a Client Component must be serializable. Temporal objects aren't, so send ISO 8601 strings and turn them into Temporal values in the client:

team-calendar.tsx
// app/calendar/team-calendar.tsx
"use client";

import { useMemo } from "react";

import { Calendar, weekRules, type Meeting } from "./quick-start";

// A Meeting with its times as ISO 8601 strings.
export type MeetingJson = Omit<Meeting, "start" | "end"> & {
  readonly start: string;
  readonly end: string;
};

interface TeamCalendarProps {
  readonly meetings: readonly MeetingJson[];
  readonly now: string;
  readonly timeZone: string;
}

export function TeamCalendar({ meetings, now, timeZone }: TeamCalendarProps) {
  const events = useMemo(
    () =>
      meetings.map((meeting): Meeting => ({
        ...meeting,
        start: Temporal.Instant.from(meeting.start),
        end: Temporal.Instant.from(meeting.end),
      })),
    [meetings],
  );
  const nowInstant = Temporal.Instant.from(now);

  return (
    <Calendar.Root
      events={events}
      now={nowInstant}
      defaultAnchorDate={nowInstant.toZonedDateTimeISO(timeZone).toPlainDate()}
      defaultTimeZone={timeZone}
      locale="en-US"
      weekRules={weekRules}
    >
      <Calendar.Frame>
        <Calendar.Toolbar>
          <Calendar.PreviousTrigger>Previous</Calendar.PreviousTrigger>
          <Calendar.TodayTrigger>Today</Calendar.TodayTrigger>
          <Calendar.NextTrigger>Next</Calendar.NextTrigger>
          <Calendar.Title />
        </Calendar.Toolbar>
        <Calendar.Viewport>
          <Calendar.ActiveView />
        </Calendar.Viewport>
      </Calendar.Frame>
    </Calendar.Root>
  );
}

useMemo keeps the converted array stable between renders, so the calendar only re-reads events when meetings actually changes.

The page fetches the data on the server:

page.tsx
// app/calendar/page.tsx (a Server Component)
import { TeamCalendar, type MeetingJson } from "./team-calendar";

async function getMeetings(): Promise<readonly MeetingJson[]> {
  // Replace with your database or API call. Return ISO 8601 strings, not Temporal objects.
  return [
    {
      id: "standup",
      title: "Standup",
      color: "blue",
      start: "2026-10-08T07:00:00Z",
      end: "2026-10-08T07:15:00Z",
    },
  ];
}

export default async function CalendarPage() {
  const meetings = await getMeetings();

  return (
    <TeamCalendar
      meetings={meetings}
      now={Temporal.Now.instant().toString()}
      timeZone="Europe/Berlin"
    />
  );
}

Match the server and the browser

During server rendering and hydration, Time doesn't read the clock, the host time zone, or window. Everything that affects the first render comes from props, so the server and the browser produce the same markup as long as they get the same props. In practice:

  • Compute "now" once on the server and pass it down, as now above. Calling Temporal.Now.instant() in a Client Component gives the server and the browser different values. Without now, anything that depends on the current time (a view's today, useNow()) is empty on the server and fills in after hydration.
  • Choose the time zone on the server, for example from the user's profile or a cookie. The server's own zone is rarely the user's.
  • Pass the same locale to both. Dates and times are formatted with Intl, which depends on it.

If you only learn the user's time zone in the browser, render the calendar after hydration, or pass the zone as a controlled timeZone and update it once you know it.

Element IDs

Root derives element IDs from React's useId, so they match between server and browser. If you render several separate React roots on one page, give each a distinct identifierPrefix when you create it (on both server and client), or pass an explicit id to each Root.

Edit on GitHub

Last updated on

On this page