Server-Side Rendering

All reelkit packages work on the server. Import and render them with Next.js, Remix, Angular SSR, Nuxt 3, or any SSR setup.

How It Works

The core slider controller is pure logic with no DOM access at construction. Gesture listeners, keyboard events, and animations attach only in client-side lifecycle hooks.

During SSR, the React Reel renders a static container with the initial visible slides (typically 3: previous, current, next) when it has a size, and an empty container when it measures itself. On hydration, it attaches gesture, keyboard, and wheel controllers to make everything interactive.

The Vue Reel renders an empty container on the server and draws its slides after hydration. With a size, that container does not match what the client renders first, so in Nuxt it goes inside <ClientOnly> (see Nuxt 3).

PackageSSR SafeNotes
@reelkit/coreYesPure logic, no browser APIs at import or construction
@reelkit/reactYesReel and ReelIndicator render valid HTML on the server
@reelkit/angularYesStandalone components, SSR compatible with Angular SSR (@angular/ssr)
@reelkit/vueYesSafe to import and render on the server; with a size, put Reel inside <ClientOnly> in Nuxt 3
@reelkit/react-reel-playerYesRenders nothing when closed (isOpen=false)
@reelkit/react-lightboxYesRenders nothing when closed (isOpen=false)
@reelkit/angular-reel-playerYesRenders nothing when closed (isOpen=false)
@reelkit/angular-lightboxYesRenders nothing when closed (isOpen=false)
@reelkit/vue-reel-playerYesRenders nothing when closed (isOpen=false)
@reelkit/vue-lightboxYesRenders nothing when closed (isOpen=false)
@reelkit/stories-coreYesFramework-agnostic, no DOM access
@reelkit/react-stories-playerYesRenders nothing when closed (isOpen=false)
@reelkit/angular-stories-playerYesRenders nothing when closed (isOpen=false)
@reelkit/vue-stories-playerYesRenders nothing when closed (isOpen=false)

Next.js App Router

Reel uses browser events and refs, so it runs as a Client Component. Add the "use client" directive at the top of the file that uses Reel:

tsx
'use client';

import { Reel, ReelIndicator } from '@reelkit/react';

export function Feed({ items }: { items: FeedItem[] }) {
  return (
    <Reel
      count={items.length}
      size={[400, 700]}
      direction="vertical"
      enableWheel
      itemBuilder={(index) => (
        <div className="w-full h-full flex items-center justify-center">
          {items[index].title}
        </div>
      )}
    >
      <ReelIndicator />
    </Reel>
  );
}

You can fetch data in a Server Component and pass it down:

tsx
// app/feed/page.tsx (Server Component)
import { Feed } from './Feed';

export default async function FeedPage() {
  const items = await fetchFeedItems();

  return <Feed items={items} />;
}

Next.js Pages Router

Pages Router works without extra configuration. The component renders during SSR and hydrates on the client:

tsx
// pages/feed.tsx
import { Reel } from '@reelkit/react';
import type { GetServerSideProps } from 'next';

interface Props {
  items: FeedItem[];
}

export const getServerSideProps: GetServerSideProps<Props> = async () => {
  const items = await fetchFeedItems();
  return { props: { items } };
};

export default function FeedPage({ items }: Props) {
  return (
    <Reel
      count={items.length}
      size={[400, 700]}
      itemBuilder={(index) => <Slide data={items[index]} />}
    />
  );
}

Responsive Size with SSR

Omit the size prop entirely. When size is not provided, Reel auto-measures its container via ResizeObserver on the client. During SSR the slider renders an empty container; on hydration it measures and renders slides immediately:

tsx
'use client';

import { Reel } from '@reelkit/react';

export function FullScreenFeed({ items }: { items: FeedItem[] }) {
  return (
    <Reel
      count={items.length}
      style={{ width: '100%', height: '100dvh' }}
      itemBuilder={(index) => <Slide data={items[index]} />}
    />
  );
}
How auto-size works

When size is omitted, the container must be sized by CSS (parent flex/grid, explicit width/height, or percentages). The slider renders nothing until the first measurement completes, then fills the measured dimensions and responds to subsequent resizes automatically.

Explicit size (manual approach)

For pixel-level control, pass an explicit size prop. Since window.innerWidth is not available during SSR, provide a default and update on mount:

tsx
'use client';

import { useState, useEffect } from 'react';
import { Reel } from '@reelkit/react';

// Default size for SSR — matches common mobile viewport
const DEFAULT_SIZE: [number, number] = [390, 844];

export function FullScreenFeed({ items }: { items: FeedItem[] }) {
  const [size, setSize] = useState<[number, number]>(DEFAULT_SIZE);

  useEffect(() => {
    const update = () =>
      setSize([window.innerWidth, window.innerHeight]);

    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return (
    <Reel
      count={items.length}
      size={size}
      itemBuilder={(index) => <Slide data={items[index]} />}
    />
  );
}
Tip

Choose a default size that matches your most common viewport (e.g. mobile-first). The slider will re-adjust instantly on hydration if the actual viewport differs.

Overlay Components

ReelPlayerOverlay, LightboxOverlay and StoriesOverlay render nothing when isOpen={false}, so they are SSR-safe by default. They only mount their portal when opened (typically from a user interaction on the client):

tsx
'use client';

import { useState } from 'react';
import { ReelPlayerOverlay } from '@reelkit/react-reel-player';

export function VideoFeed({ content }: { content: ContentItem[] }) {
  const [isOpen, setIsOpen] = useState(false);
  const [startIndex, setStartIndex] = useState(0);

  return (
    <>
      <div className="grid grid-cols-3 gap-1">
        {content.map((item, i) => (
          <button
            key={i}
            onClick={() => { setStartIndex(i); setIsOpen(true); }}
          >
            <img src={item.thumbnail} alt="" />
          </button>
        ))}
      </div>

      <ReelPlayerOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        content={content}
        initialIndex={startIndex}
      />
    </>
  );
}

Angular SSR

All Angular components are SSR-safe with @angular/ssr. The slider controller is created in ngOnInit, and the work that needs a browser, such as measuring the container, is skipped on the server through isPlatformBrowser. Overlay components render nothing when isOpen=false, so they produce no markup during server rendering.

typescript
import { Component, signal } from '@angular/core';
import {
  RkReelPlayerOverlayComponent,
} from '@reelkit/angular-reel-player';

@Component({
  selector: 'app-feed',
  standalone: true,
  imports: [RkReelPlayerOverlayComponent],
  template: `
    <rk-reel-player-overlay
      [isOpen]="isOpen()"
      [content]="content"
      (closed)="isOpen.set(false)"
    />
  `,
})
export class FeedComponent {
  isOpen = signal(false);
  content = [/* ... */];
}

Nuxt 3

ReelKit Vue components import and render on the server without errors. Reel sends an empty container from the server and draws its slides after hydration. When Reel measures itself, that container hydrates cleanly from @reelkit/vue 0.9.1; earlier versions kept it at zero height, so no slides appeared. With an explicit size, the client's first render already has slides, which gives a hydration mismatch. In that case, or on an earlier version, wrap Reel in a <ClientOnly> component or use the .client.vue suffix:

vue
<!-- pages/feed.vue -->
<script setup lang="ts">
const items = await useFetch('/api/feed');
</script>

<template>
  <ClientOnly>
    <Feed :items="items.data.value" />
  </ClientOnly>
</template>

The Feed component uses Reel normally:

vue
<!-- components/Feed.vue -->
<script setup lang="ts">
import { Reel, ReelIndicator } from '@reelkit/vue';

defineProps<{ items: FeedItem[] }>();
</script>

<template>
  <Reel :count="items.length" direction="vertical" enable-wheel>
    <template #item="{ index, size }">
      <div :style="{ width: size[0] + 'px', height: size[1] + 'px' }">
        {{ items[index].title }}
      </div>
    </template>
    <ReelIndicator />
  </Reel>
</template>

Responsive Size with SSR

Omit the size prop to use auto-measurement. The Reel auto-sizes to 100% of its parent and, from @reelkit/vue 0.9.1, needs no <ClientOnly>: the server sends an empty container at full size, and the client measures it and renders slides. The example keeps the wrapper, which also works on earlier versions:

vue
<template>
  <ClientOnly>
    <div style="width: 100%; height: 100dvh">
      <Reel :count="items.length">
        <template #item="{ index, size }">
          <Slide :data="items[index]" :size="size" />
        </template>
      </Reel>
    </div>
  </ClientOnly>
</template>

Overlay Components

ReelPlayerOverlay, LightboxOverlay and StoriesOverlay render nothing when isOpen is false, so they need no <ClientOnly> and produce no markup during server rendering. They teleport their content to the body only once opened on the client.

Using Core Directly

When using @reelkit/core directly for a custom framework integration, you can create the controller on the server. Call attach() and observe() on the client:

typescript
import { createSliderController } from '@reelkit/core';

// Safe to call on the server — no DOM access
const controller = createSliderController({
  count: 10,
  direction: 'vertical',
});

// Only call on the client — attaches DOM event listeners
if (typeof window !== 'undefined') {
  controller.attach(element);
  controller.observe();
}

Summary

What works out of the box
  • Importing any reelkit package on the server
  • Rendering slider components during SSR (produces valid static HTML)
  • Creating controllers on the server
  • Overlay components when isOpen=false
What to keep in mind
  • Omit size for auto-measurement, or provide a default when using viewport-based dimensions
  • In Nuxt, render the Vue Reel inside <ClientOnly> when it has a size, or on @reelkit/vue before 0.9.1
  • Don't call attach()/observe() on the server when using core directly