> ## Documentation Index
> Fetch the complete documentation index at: https://bunnynet-cb9733c2-stream-player-framework-guides.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Use Bunny Player with Next.js

> Embed the Bunny Stream player in a Next.js app with a client component that survives server rendering, and control playback with player.js.

The Bunny Player is an iframe, and [player.js](https://github.com/embedly/player.js) gives you a `Player` object for talking to it over `postMessage`. Server rendering adds two constraints. player.js reads `window` the moment it's imported, so it has to be imported in the browser. And a `Player` only connects if it exists before its iframe finishes loading, which server-rendered iframe HTML can do before your JavaScript hydrates.

The component below renders a placeholder on the server, loads player.js in an effect, then mounts the iframe and creates the `Player` in the same commit. It uses the App Router and works unchanged with the Pages Router. The [React guide](/stream/player/react) covers the client-only version.

## Quickstart

<Steps>
  <Step title="Install player.js">
    <CodeGroup>
      ```bash npm theme={null}
      npm install player.js
      ```

      ```bash pnpm theme={null}
      pnpm add player.js
      ```

      ```bash yarn theme={null}
      yarn add player.js
      ```

      ```bash bun theme={null}
      bun add player.js
      ```
    </CodeGroup>

    player.js ships without types. Add a declaration file anywhere your `tsconfig.json` includes, for example `player.js.d.ts`. It covers the methods and events the Bunny Player supports:

    ```ts player.js.d.ts theme={null}
    declare module "player.js" {
      export type PlayerEvent =
        | "ready"
        | "play"
        | "pause"
        | "ended"
        | "timeupdate"
        | "progress"
        | "seeked"
        | "error"
        | "playbackratechange";

      export type TimeUpdate = { seconds: number; duration: number };
      export type Progress = { percent: number; seconds: number; duration: number };
      /** Present when a command fails. Empty when the media itself errors. */
      export type PlayerError = { code: number; msg: string };

      export class Player {
        constructor(iframe: HTMLIFrameElement | string);

        on(event: "ready", callback: () => void): void;
        on(event: "timeupdate", callback: (data: TimeUpdate) => void): void;
        on(event: "progress", callback: (data: Progress) => void): void;
        on(event: "playbackratechange", callback: (rate: number) => void): void;
        on(event: "error", callback: (error?: PlayerError) => void): void;
        on(event: PlayerEvent, callback: (data?: unknown) => void): void;
        off(event: PlayerEvent, callback?: (...args: unknown[]) => void): void;
        supports(kind: "method" | "event", name: string | string[]): boolean;
        /** Send a raw command, for methods player.js does not expose such as setPlaybackRate. */
        send(message: { method: string; value?: unknown }): void;

        play(): void;
        pause(): void;
        mute(): void;
        unmute(): void;
        setVolume(percent: number): void;
        setCurrentTime(seconds: number): void;
        setLoop(loop: boolean): void;

        getPaused(callback: (paused: boolean) => void): void;
        getMuted(callback: (muted: boolean) => void): void;
        getVolume(callback: (percent: number) => void): void;
        getDuration(callback: (seconds: number) => void): void;
        getCurrentTime(callback: (seconds: number) => void): void;
        getLoop(callback: (loop: boolean) => void): void;
      }

      const playerjs: { Player: typeof Player };
      export default playerjs;
    }
    ```
  </Step>

  <Step title="Create the client component">
    ```tsx components/bunny-player.tsx theme={null}
    "use client";

    import { useEffect, useRef, useState, type CSSProperties } from "react";
    import type { Player, TimeUpdate } from "player.js";

    type PlayerJs = (typeof import("player.js"))["default"];

    export type BunnyPlayerProps = {
      libraryId: string;
      videoId: string;
      /** Player parameters such as autoplay, muted, captions, or t. */
      params?: Record<string, string | number | boolean>;
      title?: string;
      onReady?: (player: Player) => void;
      onPlay?: () => void;
      onPause?: () => void;
      onEnded?: () => void;
      onTimeUpdate?: (time: TimeUpdate) => void;
    };

    const frameStyle: CSSProperties = {
      display: "block",
      width: "100%",
      height: "auto",
      aspectRatio: "16 / 9",
      border: 0,
      background: "#000",
    };

    export function BunnyPlayer({
      libraryId,
      videoId,
      params,
      title = "Video player",
      onReady,
      onPlay,
      onPause,
      onEnded,
      onTimeUpdate,
    }: BunnyPlayerProps) {
      const iframeRef = useRef<HTMLIFrameElement>(null);
      const [playerjs, setPlayerjs] = useState<PlayerJs | null>(null);

      // Keep the latest callbacks without re-creating the player.
      const handlers = useRef({ onReady, onPlay, onPause, onEnded, onTimeUpdate });
      useEffect(() => {
        handlers.current = { onReady, onPlay, onPause, onEnded, onTimeUpdate };
      });

      // player.js reads window when imported, so load it in the browser only.
      useEffect(() => {
        let cancelled = false;
        import("player.js").then((mod) => {
          if (!cancelled) setPlayerjs(mod.default);
        });
        return () => {
          cancelled = true;
        };
      }, []);

      const query = new URLSearchParams(
        Object.entries(params ?? {}).map(([key, value]) => [key, String(value)]),
      ).toString();
      const src = `https://player.mediadelivery.net/embed/${libraryId}/${videoId}${query ? `?${query}` : ""}`;

      useEffect(() => {
        const iframe = iframeRef.current;
        if (!playerjs || !iframe) return;

        // player.js has no teardown API. This flag stops stale listeners
        // from firing after the video changes or the component unmounts.
        let active = true;
        const player = new playerjs.Player(iframe);

        player.on("ready", () => active && handlers.current.onReady?.(player));
        player.on("play", () => active && handlers.current.onPlay?.());
        player.on("pause", () => active && handlers.current.onPause?.());
        player.on("ended", () => active && handlers.current.onEnded?.());
        player.on("timeupdate", (time) => active && handlers.current.onTimeUpdate?.(time));

        return () => {
          active = false;
        };
      }, [playerjs, src]);

      // Hold the space until player.js is loaded so the iframe cannot
      // finish loading before the Player exists.
      if (!playerjs) {
        return <div style={frameStyle} aria-hidden="true" />;
      }

      return (
        <iframe
          ref={iframeRef}
          src={src}
          title={title}
          style={frameStyle}
          allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
          allowFullScreen
        />
      );
    }
    ```

    The placeholder is a black box at the same aspect ratio. Swap in the video's thumbnail if you want a poster while the script loads; [Video storage structure](/stream/storage-structure) has the URL.
  </Step>

  <Step title="Render it from a Server Component">
    The component is a Client Component, so a server-rendered page can pass it data fetched on the server:

    ```tsx app/lessons/[id]/page.tsx theme={null}
    import { BunnyPlayer } from "@/components/bunny-player";
    import { getLesson } from "@/lib/lessons";

    export default async function LessonPage({
      params,
    }: {
      params: Promise<{ id: string }>;
    }) {
      const { id } = await params;
      const lesson = await getLesson(id);

      return (
        <main>
          <h1>{lesson.title}</h1>
          <BunnyPlayer
            libraryId={process.env.NEXT_PUBLIC_BUNNY_LIBRARY_ID!}
            videoId={lesson.videoId}
            params={{ autoplay: false, preload: true }}
          />
        </main>
      );
    }
    ```

    The library ID already appears in every embed URL, so a `NEXT_PUBLIC_` variable is fine. `params` accepts any [player parameter](/stream/embedding#supported-parameters).
  </Step>
</Steps>

## Control playback

Callback props are functions, so the component that passes them has to be a Client Component too. `onReady` hands you the `Player`:

```tsx components/lesson-player.tsx theme={null}
"use client";

import { useState } from "react";
import type { Player } from "player.js";
import { BunnyPlayer } from "@/components/bunny-player";

export function LessonPlayer({ libraryId, videoId }: { libraryId: string; videoId: string }) {
  const [player, setPlayer] = useState<Player | null>(null);
  const [playing, setPlaying] = useState(false);

  return (
    <>
      <BunnyPlayer
        libraryId={libraryId}
        videoId={videoId}
        onReady={setPlayer}
        onPlay={() => setPlaying(true)}
        onPause={() => setPlaying(false)}
      />
      <button onClick={() => (playing ? player?.pause() : player?.play())}>
        {playing ? "Pause" : "Play"}
      </button>
      <button onClick={() => player?.setCurrentTime(0)}>Restart</button>
    </>
  );
}
```

Getters take a callback, because the answer comes back from the iframe: `player.getCurrentTime((seconds) => ...)`. The `player.js` package on npm has no playback speed method, so send the command directly. The build bunny.net hosts adds `setPlaybackRate()` and `getPlaybackRate()`; see [Methods](/stream/playback-api#methods).

```tsx theme={null}
player.send({ method: "setPlaybackRate", value: 1.5 });
```

[Playback control API](/stream/playback-api) lists every method and event.

## Save progress with a Server Action

`timeupdate` fires several times a second with `{ seconds, duration }`. Throttle it before calling a Server Action:

```ts app/actions.ts theme={null}
"use server";

export async function saveProgress(videoId: string, seconds: number, duration: number) {
  // Persist to your database, keyed by the signed-in user.
}
```

```tsx components/lesson-player.tsx theme={null}
"use client";

import { useRef } from "react";
import { saveProgress } from "@/app/actions";
import { BunnyPlayer } from "@/components/bunny-player";

export function LessonPlayer({ libraryId, videoId, resumeAt }: { libraryId: string; videoId: string; resumeAt?: number }) {
  const lastSaved = useRef(0);

  return (
    <BunnyPlayer
      libraryId={libraryId}
      videoId={videoId}
      params={resumeAt ? { t: resumeAt } : undefined}
      onTimeUpdate={({ seconds, duration }) => {
        if (seconds - lastSaved.current < 5) return;
        lastSaved.current = seconds;
        void saveProgress(videoId, seconds, duration);
      }}
    />
  );
}
```

Pass the saved position back as the `t` parameter to resume from there.

## Sign embed URLs on the server

If the library uses [embed view token authentication](/stream/token-authentication), the iframe URL needs a `token` and `expires` pair. Compute them in the Server Component, where the library's security key stays, and pass them through `params`:

```tsx theme={null}
<BunnyPlayer
  libraryId={libraryId}
  videoId={lesson.videoId}
  params={{ token, expires }}
/>
```

## Load player.js from the CDN instead

To skip the npm dependency, load the hosted build in the root layout with `beforeInteractive`, so `window.playerjs` exists before anything hydrates:

```tsx app/layout.tsx theme={null}
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://assets.mediadelivery.net/playerjs/playerjs-latest.min.js"
          strategy="beforeInteractive"
        />
      </body>
    </html>
  );
}
```

Replace the dynamic import with `setPlayerjs(window.playerjs)` and declare the global:

```ts theme={null}
declare global {
  interface Window {
    playerjs: (typeof import("player.js"))["default"];
  }
}
```

Keep the placeholder. A server-rendered iframe can still finish loading before hydration.

## Troubleshooting

<AccordionGroup>
  <Accordion title="ReferenceError: window is not defined">
    player.js is being imported on the server. Keep `import("player.js")` inside `useEffect`. A static `import playerjs from "player.js"` at the top of a Client Component still runs during server rendering.
  </Accordion>

  <Accordion title="onReady never fires">
    The iframe finished loading before the `Player` existed, which happens when the iframe is part of the server-rendered HTML. Render it only after player.js has loaded, as the component above does.
  </Accordion>

  <Accordion title="Events fire twice after changing the video">
    Every `Player` adds a `message` listener to `window` that is never removed. The `active` flag in the effect cleanup keeps stale instances quiet, so check that yours flips it.
  </Accordion>

  <Accordion title="The iframe shows a 403">
    The library's allowed domains, direct access block, or token authentication is rejecting the embed. See [Embedding restrictions](/stream/embedding#embedding-restrictions).
  </Accordion>
</AccordionGroup>
