---
title: "Embed a video"
audience: "anyone putting YouTube or Vimeo videos on a page"
description: "You'll put two YouTube videos on a page. The server renders each one as a still with a play button. The player only loads for the one a visitor actually…"
snippet_caption: ""
snippet: ""
---

import { Install } from "@src/ui/Install";

You'll put two YouTube videos on a page. The server renders each one as a still with a play button. The player only loads for the one a visitor actually clicks. Then you'll move the stills onto your own origin, so the page asks YouTube for nothing until someone presses play.

<Install captured="0.x" />

## What an iframe costs

A YouTube iframe is a whole player: scripts, styles and a connection to Google before the visitor has decided to watch. Two videos on a page is two of those on load. fsr has a "lite embed" built in. The server renders it, so you place it straight in a page.

## Place the videos

```shell-session
$ fsr new videos --with react
```

Replace the index page:

```tsx title="app/routes/page.tsx"
import type { RootProps } from "@generated/client";
import { Embed } from "@snapfire/fsr-client/react";

export default function Index({ greeting }: RootProps) {
  return (
    <section>
      <h1>{greeting}</h1>
      <p>Two videos. Neither loads a player until you press play.</p>
      <div className="video">
        <Embed provider="youtube" id="f_WuRfuMXQw" title="Rick Astley - Never Gonna Give You Up" />
      </div>
      <div className="video">
        <Embed provider="youtube" id="dQw4w9WgXcQ" title="Rick Astley - Never Gonna Give You Up (original)" />
      </div>
    </section>
  );
}
```

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr";

export async function load(_ctx: Ctx<"/">) {
  return { greeting: "Videos" };
}
```

`provider` and `id` are written out as strings so the build can read them. `title` is required: it's drawn on the still, read by a screen reader and given to the player. The still keeps a 16:9 box, so nothing jumps when it loads or when the player replaces it. Size it with the element around it, at the end of `app/styles/app.css`:

```css title="app/styles/app.css"
.video {
  margin: 24px 0;
  max-width: 640px;
}
```

```shell-session
$ fsr dev videos/app
```

Open `http://127.0.0.1:3000`. Each video is its thumbnail with the title across the top and a play button in the middle.

## Count the requests

Open the browser's network panel and reload. Grouped by origin, the page made:

```text
(this origin)       17
https://i.ytimg.com  2
```

Two images from YouTube's image server, one per still. Nothing came from the player's domain. Click the first video. Its still is replaced by the player:

```text
(this origin)                    17
https://i.ytimg.com               2
https://www.youtube-nocookie.com  1
```

One player, for the video you chose. The second is still a still. The player comes from `youtube-nocookie.com`, YouTube's domain for embeds that sets no cookie until the video plays.

## The policy already knows

The scaffold sends a Content-Security-Policy in report-only mode. You didn't touch it, but it already allows exactly what the embeds need:

```shell-session
$ curl -s -D - -o /dev/null http://127.0.0.1:3000/ | grep -oE "(frame-src|img-src)[^;]*"
frame-src 'self' https://www.youtube-nocookie.com
img-src 'self' data: https://i.ytimg.com
```

The build read each `Embed` and added the player's origin to `frame-src` and the image server to `img-src`. Rename the table to `[document.csp]` to enforce it and the videos keep working.

## Keep YouTube off the page until play

The stills still come from YouTube, which tells Google someone opened the page. To serve them yourself, add this to `config/app.toml`:

```toml title="config/app.toml"
[media]
embed_stills = "local"
```

The build fetches each still once into `app/generated/embeds/` and serves it from your origin under a content hash. Reload and the network panel shows only your own origin:

```text
(this origin)  19
```

The policy drops the image server, since nothing needs it any more:

```shell-session
$ curl -s -D - -o /dev/null http://127.0.0.1:3000/ | grep -oE "(frame-src|img-src)[^;]*"
frame-src 'self' https://www.youtube-nocookie.com
img-src 'self' data:
```

`frame-src` keeps the player's domain, since the video itself lives there. If the build can't reach YouTube, on a machine with no network say, it falls back to YouTube's still and says so in its output, so a deploy never ships a broken image.

## Try it

Move one `Embed` into a component of its own, `app/src/ui/Pick.tsx`, that holds state with `useState`. Place `<Pick />` on the page. The build refuses it:

```text
`src/ui/Pick.tsx#Pick` places an `<Embed>` and hydrates, but the browser has no still to render it with; place the `<Embed>` in a page or a component that does not hydrate
```

An `Embed` is rendered by the server and the click is wired up by the page's boot script, so a component the browser hydrates has nothing to show. Keep it in a page, a layout or a component that renders on the server.

Then swap one video for Vimeo: `provider="vimeo"` with a Vimeo id. Vimeo names its stills differently, so the build asks Vimeo's public video API once and keeps the answer under `app/generated/embeds/`.
