# Server rendering and prerendered pages

> Use SuperScheduler in SSR or statically prerendered apps: ship a meaningful shell, reserve the space, mount the DOM engine on the client and keep CSP strict.

Source: https://superscheduler.org/en/docs/ssr-prerender/
Reviewed: 2026-10-07

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.

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.

> **Behavior:**
> React content given to `emptyState` or `errorState` is rendered through portals that are created in the browser, so it does not appear in server HTML either.

## 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.

→ https://superscheduler.org/en/examples/hotel-rooms/
## 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.

```tsx
// src/PlanningPage.tsx
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>
  )
}
```
```tsx
// src/Planning.tsx
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:

```css
.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.

```txt
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com
```

> **Limitation:**
> HTML strings you give the scheduler (event `html`, cell `html`, `bubbleHtml`, menu item `html`, area `html`) are inserted with `innerHTML`. Under a strict `style-src`, inline `style="..."` attributes inside them are blocked, so use classes. Inline event handler attributes are blocked by `script-src`, as they should be. The library assigns HTML strings with `innerHTML`, yours and those of its own menus, messages and drag feedback, and creates no Trusted Types policy: pages that enforce `require-trusted-types-for 'script'` need a default policy.

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](https://superscheduler.org/en/docs/react-integration/), [Virtualization and performance](https://superscheduler.org/en/docs/performance-virtualization/), [Theming](https://superscheduler.org/en/docs/theming/) and [Troubleshooting](https://superscheduler.org/en/docs/troubleshooting/).
