Tutorial 30 of 54 · 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/components";

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/host";

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, the placeholder shows the title alone and the build names it in an embed row. The next build tries again. [build] strict refuses a build with such a row.

Try it#

Move one Embed into a component of its own, app/src/ui/Pick.tsx, that the browser renders alone. Place <Pick /> on the page. The build report says what that costs:

embed     src/ui/Pick.tsx#Pick               renders in the browser, which holds no local still, so the placeholder shows the title alone

An Embed gets its still from the server, so a component the browser renders on its own has no still to show and the browser warns in the console. 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/.