Hapio Calendar

SSR and React Server Components

What renders on the server, what the server needs to know for a complete render, and what may cross from a Server Component.

The calendar renders on the server like any React component and hydrates without mismatches. What the server can render depends on two things only it may not know: the time zone and today.

A complete server render

The server renders the whole calendar, events included, when it knows:

  1. the zone: an IANA timeZone, not 'local' (the default), which only the browser knows;
  2. the date: date or defaultDate, or now, so that 'today' means a day.

Without them the calendar is pending on the server and appears after hydration, without a layout shift. With the default timeZone ('local') the block renders its skeleton at the root's size inside a <Suspense> boundary of its own; with an explicit zone but no date the toolbar renders and the view's skeleton stands in at its size. That is often fine for a calendar below the fold; for one that is the page's main content, pass both.

With an explicit timeZone the block has no <Suspense> boundary of its own (it decides once, when it mounts), so a complete server render is in the HTML itself and shows even before, or without, JavaScript. Content of yours that suspends inside it, such as a component in renderEvent that reads a promise, suspends to your app's nearest boundary: give it a <Suspense> of its own.

With a date but no now, today is unknown on the server: the period renders, and today's marks (the today column, past days, the now indicator) appear once the browser's clock is known. now fixes the clock to an instant, which stops it: use it for tests and fixed schedules, not to render "today" on the server.

Server Components

HapioCalendar is a client component (its file starts with 'use client'). A Server Component can render it directly with props that can cross to the client: plain data (events of strings, numbers and dates), view names, the zone and the locale. Functions cannot cross: callbacks, render functions, accessors, and view maps whose formats are functions belong in a client component around the calendar.

@hapio/calendar-core is safe to import in Server Components, so a page can load exactly the visible range before it renders:

app/bookings/page.tsx
// A Server Component: it loads the first week, the client calendar takes over from there.
import { defaultViews, getVisibleRange, isPlainDateString } from '@hapio/calendar-core'

import { fetchBookings } from '@/lib/bookings'

import { WeekCalendar } from './week-calendar'

const TIME_ZONE = 'Europe/Stockholm'

/** The week to show: `?date=` when it is a real date, else this week. */
function weekOf(date: string | undefined) {
  return getVisibleRange(defaultViews.week, {
    date: date !== undefined && isPlainDateString(date) ? date : 'today',
    now: Date.now(),
    timeZone: TIME_ZONE,
    weekStartsOn: 1,
  })
}

export default async function BookingsPage({
  searchParams,
}: {
  searchParams: Promise<{ date?: string }>
}) {
  const { date } = await searchParams
  const week = weekOf(date)
  const bookings = await fetchBookings(week.startIso, week.endIso, AbortSignal.timeout(5000))
  return (
    <WeekCalendar
      initialBookings={bookings}
      defaultDate={week.currentStartIso}
      timeZone={TIME_ZONE}
    />
  )
}
app/bookings/week-calendar.tsx
'use client'

import type { CalendarEventInputBase } from '@hapio/calendar-react'

import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import type { Booking } from '@/lib/bookings'

function toCalendarEvent(booking: Booking): CalendarEventInputBase {
  return { id: booking.id, title: booking.title, start: booking.startsAt, end: booking.endsAt }
}

export function WeekCalendar({
  initialBookings,
  defaultDate,
  timeZone,
}: {
  initialBookings: readonly Booking[]
  defaultDate: string
  timeZone: string
}) {
  // A zone and a date: the server renders the whole week, events included.
  return (
    <HapioCalendar
      eventItems={initialBookings}
      eventAccessor={toCalendarEvent}
      defaultDate={defaultDate}
      timeZone={timeZone}
      locale="en-GB"
      weekStartsOn={1}
      className="h-[max(36rem,calc(100svh-17rem))]"
    />
  )
}
  • getVisibleRange(view, { date, now, timeZone, weekStartsOn, hiddenDays }) returns the range a view shows for a date, exactly as onRangeChange will report it. It never reads the clock: date: 'today' needs now.
  • isPlainDateString checks a URL's date before it reaches the calendar.
  • expandRecurrences (@hapio/calendar-recurrence) expands recurring series on the server; see Recurrence.

The interaction engines (selection(), editing(), externalDrops()) and the recurrence engine must be created in a client module: an engine is a handle whose code is registered in the module that created it, so one passed from a Server Component arrives as a copy without its code, and development builds say so. The registry's wrappers create theirs at module scope.

Event layers on the client: ssrEvents

ssrEvents={false} leaves the time and day grids' event layers out of the server render; they mount right after hydration. It keeps a large week's events out of the HTML and the hydration payload. It is not enough on its own for a Content-Security-Policy without inline styles; see CSP.

Hydration and StrictMode

The calendar's ids come from useId, the server snapshot of its store has no clock and no interaction state, and every effect is idempotent, so hydration produces no warnings, StrictMode's double mount changes nothing, and <Activity> can hide a calendar (its clock and requests stop) and show it again with its state.