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:
- the zone: an IANA
timeZone, not'local'(the default), which only the browser knows; - the date:
dateordefaultDate, ornow, 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:
// 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}
/>
)
}'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 asonRangeChangewill report it. It never reads the clock:date: 'today'needsnow.isPlainDateStringchecks 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.