Skip to content
SuperScheduler

ProductionApplies toLite and Pro

Server rendering and prerendered pages

On the server, both the Pro and Lite components render an empty `<div>`; the scheduler is built in the browser after the component mounts. Render a shell with the same height on the server, with real content such as upcoming bookings, then mount the scheduler on the client, ideally from a lazily imported module. The packages can be imported on the server, they hydrate without mismatches, and they need no inline scripts or inline styles from your Content Security Policy.

Verified against v0.1.0 · reviewed October 7, 2026.md

SuperScheduler draws its grid with an imperative DOM engine, and that engine needs a browser: it measures the viewport, listens to scroll and pointer events, and positions nodes as you scroll. Server rendering and static prerendering still work well with it, as long as you decide what the server sends while the browser builds the real grid. This applies to both editions.

What the server renders

SuperSchedulerComponent, from super-scheduler, super-scheduler/react-render or super-scheduler-lite, renders a single empty <div> on the server. The control is created in componentDidMount, which never runs on the server, so:

  • the prerendered HTML contains no rows, events or headers;
  • ref.current.control and controlRef stay empty until the browser mounts the component;
  • importing the packages on the server is safe: no module touches window or document at import time, including the Pro subpath modules;
  • hydration matches: the browser's first render is the same empty <div>, and the control fills it after hydration.

Ship a meaningful shell

An empty box until JavaScript runs is a poor first paint and an empty page for crawlers and for readers without JavaScript. Render a stand-in on the server instead:

  • Reserve the space. Give the container the height the scheduler will have, in CSS, so nothing below it moves when the grid appears (no layout shift).
  • Show real content. A heading, the toolbar labels and a short list of today's or upcoming bookings tell visitors and search engines what the page is for. The shell can use the same data as the scheduler.
  • Mark it as loading. aria-busy="true" on the shell tells assistive technology that the region is still being built.
  • Keep the static parts outside. Titles, legends and filters that do not depend on the control can be server-rendered for good and stay when the grid mounts.

The example pages on this site work this way: each one is prerendered with a static preview of the first view, and the live scheduler replaces it when the visitor starts the demo.

Hotel room planningA shower leaks in Room 104. Rehouse the next guest, block the room for the plumber and find the nights that are already full.

Mount on the client

Render the shell on the server and during hydration, then switch to the scheduler. useSyncExternalStore with a server snapshot of false gives a flag that is false in both places and true right after hydration, without a mismatch. Loading the scheduler with React.lazy keeps its code out of the first bundle; the shell doubles as the Suspense fallback while the chunk downloads.

src/PlanningPage.tsxtsx
import { Suspense, lazy, useSyncExternalStore } from 'react'
import { SuperScheduler } from 'super-scheduler'

// Fetched in the browser only, after hydration: the scheduler stays out of the page's first bundle.
const Planning = lazy(() => import('./Planning'))

const subscribe = () => () => {}

/** False on the server and during hydration, true afterwards: no hydration mismatch. */
function useIsClient(): boolean {
  return useSyncExternalStore(
    subscribe,
    () => true,
    () => false,
  )
}

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  readonly events: SuperScheduler.EventData[]
}

export function PlanningPage({ rooms, events }: Props) {
  const isClient = useIsClient()
  const shell = <PlanningShell rooms={rooms} events={events} />
  return (
    // .planning reserves the scheduler's height in CSS, so the page does not move when it mounts.
    <section className="planning" aria-label="Room planning">
      {isClient ? (
        <Suspense fallback={shell}>
          <Planning rooms={rooms} events={events} />
        </Suspense>
      ) : (
        shell
      )}
    </section>
  )
}

/** Server-rendered stand-in with real content, at the same size as the grid. */
function PlanningShell({ rooms, events }: Props) {
  const roomName = new Map(rooms.map((room) => [room.id, room.name]))
  return (
    <div className="planning__shell" aria-busy="true">
      <h2>Upcoming stays</h2>
      <ul>
        {events.slice(0, 12).map((event) => (
          <li key={String(event.id)}>
            {new SuperScheduler.Date(event.start).toString('d MMM', 'en-us')}
            {' · '}
            {event.resource === undefined ? '' : roomName.get(event.resource)}
            {' · '}
            {event.text}
          </li>
        ))}
      </ul>
    </div>
  )
}
src/Planning.tsxtsx
import { useMemo } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'

const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Month' },
  { groupBy: 'Day', format: 'd' },
]

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  readonly events: SuperScheduler.EventData[]
}

/** Loaded with React.lazy, so it needs a default export. */
export default function Planning({ rooms, events }: Props) {
  // The control splices the array it receives: give it its own copy.
  const owned = useMemo(() => events.slice(), [events])
  return (
    <SuperSchedulerComponent
      startDate="2026-10-01"
      days={31}
      scale="Day"
      cellWidth={44}
      // Fills .planning, whose height is fixed in CSS.
      height="100%"
      timeHeaders={TIME_HEADERS}
      resources={rooms}
      events={owned}
    />
  )
}

height="100%" fills the component's host element, which is an unstyled <div>, so size it from CSS:

csscss
.planning {
  height: 560px;
}
/* The scheduler's host element and the shell fill the reserved box. */
.planning > div {
  height: 100%;
}
.planning__shell {
  overflow: auto;
}

You should see the list of stays in the page source and on first paint, then the grid in the same box a moment after the page becomes interactive, with no shift of the content below.

Import super-scheduler/styles.css (or super-scheduler-lite/styles.css) once from your root layout or global stylesheet, so it is part of the CSS the server already links. Importing it from the lazily loaded module also works when your bundler splits CSS per chunk.

Framework notes

Next.js

Neither package marks its modules with the 'use client' directive. In the App Router, render the scheduler from your own Client Component: a file that starts with 'use client' and imports SuperSchedulerComponent. Client Components are still rendered on the server, so the scheduler arrives as an empty <div> and the shell pattern above applies unchanged. To skip server rendering of that component entirely, load it with next/dynamic and { ssr: false, loading: () => <Shell /> } from inside a Client Component; the App Router does not accept ssr: false in Server Components. In the Pages Router, next/dynamic with ssr: false works directly in a page. Import the stylesheet in the root layout (App Router) or in pages/_app (Pages Router).

React Router and Remix

Route modules render on the server in SSR mode and at build time when you prerender, so use the client flag and lazy import shown above inside the route component. Data for the shell can come from the route's loader, which keeps server and client renders identical. Import the stylesheet from the root route or your global CSS.

Other frameworks

The rule is the same everywhere: render a sized placeholder wherever the framework renders on the server, and mount the component only in the browser, for example as a client-only island.

Hydration

The library itself produces no hydration mismatches. Mismatches usually come from the shell:

  • Do not compute dates from the clock during render. The server and the browser can disagree about "today", and about the time zone. Pass the dates from your loader, or compute them after mount.
  • Format shell dates with an explicit locale, as toString('d MMM', 'en-us') does above, and never with the device's defaults.
  • Under StrictMode, development mounts components twice; the component creates a fresh control on each mount and disposes the previous one.

Content Security Policy

SuperScheduler works under a strict policy:

  • Scripts. No inline scripts, eval or new Function. Code loads as modules from your bundle, including the chunks that Pro loads on demand with dynamic import() (keyboard support, menus, bubbles, language packs). script-src 'self', or the origin that serves your bundle, is enough.
  • Styles. The stylesheet is a regular CSS file, so style-src 'self' covers it. The engine positions nodes by writing to element.style through the CSSOM, which style-src does not restrict, and it injects no <style> elements. You do not need 'unsafe-inline' for the library.
  • Images. The Pro stylesheet draws a few small icons and skeleton shapes as data: SVG images, such as the event delete button. Allow them with img-src 'self' data:, or those decorations will not appear.
txttxt
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com

Your own server-rendered markup follows the same rules: React's style prop becomes a style attribute in server HTML, which a strict style-src blocks before hydration. That is why the example sizes the container with a class.

Checklist

  • Shell rendered on the server, with the scheduler's final height reserved in CSS.
  • Scheduler mounted only in the browser, from a lazily imported module.
  • Stylesheet imported once from the root layout or global CSS.
  • No clock-dependent or device-locale output in server renders.
  • CSP with script-src 'self', style-src 'self' and img-src 'self' data:; classes instead of inline styles in your HTML strings.

Related guides: React integration, Virtualization and performance, Theming and Troubleshooting.