Tutorial 25 of 36 · for anyone putting YouTube or Vimeo videos on a page · Markdown

Embed a video

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 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#

$ fsr new videos --with react

Replace the index page:

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>
  );
}
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:

app/styles/app.css
.video {
  margin: 24px 0;
  max-width: 640px;
}
$ 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:

(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:

(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:

$ 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:

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:

(this origin)  19

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

$ 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:

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