The application · for everyone · Markdown

108. Images and fonts

For app developers. How an image a component imports becomes a <picture> served in the sizes a page needs, how a font under fonts/ reaches the page with no layout shift and what each of the two costs.

An image is an import

In a component, import the file and place it with Picture:

tsx
import { Picture } from "@snapfire/fsr-client/react";
import hero from "../img/hero.png";

export default function Page() {
  return <Picture src={hero} alt="The harbour at dusk" sizes="(min-width: 60em) 50vw, 100vw" priority />;
}

The import is not a string. The bundle binds hero to the hashed URL the file is served under and the width and height it read from the file's header, so the browser module and the server agree on every number. The build reads the same import to write the markup the server renders:

html
<picture>
  <source type="image/avif" srcset="/static/js/app/img/hero.3f2a9c1e.640.avif 640w, …, /static/js/app/img/hero.3f2a9c1e.1600.avif 1600w" sizes="(min-width: 60em) 50vw, 100vw">
  <source type="image/webp" srcset="…">
  <img src="/static/js/app/img/hero.3f2a9c1e.png" width="1600" height="900" alt="The harbour at dusk" loading="eager" decoding="async" fetchpriority="high">
</picture>

Three things follow from the markup. The browser picks the width that fits the slot from srcset, so a 300px card fetches the 640 variant and a phone at two pixels per CSS pixel fetches 960. width and height are on the element before any byte of the image arrives, so the page lays out once. The URL carries the file's hash, so the host serves it with a year's immutable lifetime and a changed file gets a new name.

priority is for the one image that is the largest thing above the fold. It loads eagerly at high priority and the page carries a <link rel="preload"> for it in the head, which the browser fetches before the stylesheet parses. Everything else is lazy.

Where the variants come from

fsr build reads every image a component imports, chooses its widths under the [images] policy and, after the bundle runs, writes the hashed original and the variants under dist/. The original a browser without AVIF or WebP falls back to is the file as saved less its metadata: GPS position, camera, capture time and an embedded thumbnail go, while the orientation the browser rotates by, the colour profile and the EXIF creator and copyright stay. No pixel is re-encoded. That holds for JPEG, PNG, WebP and GIF; fsr doctor names an AVIF original that carries metadata, since the build leaves one as saved. strip = false under [images] serves every original as saved. The bundle places nothing of its own: the build hands the compiler the URL of every image and font it defined. An import the build had not seen, from a module only the browser runs, comes back to it, is read and gets its variants like the rest:

toml
[images]
widths = [640, 960, 1280, 1920, 2560]
formats = ["avif", "webp"]
quality = { avif = 60, webp = 80 }

Those are the defaults; an application that writes nothing gets them. An image gets every policy width below its own, then its own, so a 400px logo gets one variant and a 4000px hero six. Nothing is ever upscaled. Each variant is named with the source's hash, so the second build writes nothing and a dev save costs nothing after the first; fsr build --verbose says what it wrote:

text
image     src/img/hero.png                   1600x900, 8 variants
derived   8 files under dist/, 0 already there

An SVG scales itself and an animated GIF would have every frame re-encoded, so both are served as they are, with width and height and no <picture>. An SVG's size is its root element's width and height, with its viewBox taking over when those are missing or relative; one with neither has no size to write and is refused.

A photo straight off a phone is stored on its side with an EXIF orientation tag the browser rotates by. The build reads the tag, so width and height are the size the photo displays at and every variant is written upright, since a variant carries no tag.

A catalog rendered from data

A page rendered from a service's answer does not know at build time which image it shows. Write the record of imports and index it by the value:

tsx
import one from "../img/products/1.png";
import two from "../img/products/2.png";

export const PHOTOS = { "1.png": one, "2.png": two };

export function Thumb({ image }: { image: Image }) {
  return <Picture src={PHOTOS[image.file]} alt="" sizes="300px" />;
}

The build lowers one branch per entry, so the server writes the right <picture> for the value it was given and the browser, holding the same record, writes the same one. A key no entry has renders nothing.

Such a record asks for no preload, since the key is a value. The page's loader knows the value, so its meta names the one to preload, on a route that is not streamed:

ts
import { preloadImage } from "@snapfire/fsr/head";
import { PHOTOS } from "@src/ui/photos";

export const meta = ({ data }: MetaCtx<DataOf<typeof load>>) => ({
  title: "Today's picks",
  head: data.products.length > 0 ? [preloadImage(PHOTOS[data.products[0].image.file], "300px")] : [],
});

A route with a loading.tsx streams its page after the head is sent, so a preload named by its loader arrives with the segment, too late to matter. Mark the image priority instead and let fetchpriority do the work when the segment lands.

A remote image

An image on another origin, a CMS or an object store, is a string src. Through a named source it gets a srcset the service answers:

toml
[images.sources.cms]
template = "https://img.example.com/{src}?w={width}&auto=format"
tsx
<Picture src={product.photo} source="cms" alt="" width={800} height={600} />

The build writes one URL per policy width with {src} and {width} filled. No byte passes through fsr. The service resizes, which an image CDN or a CMS already does. It negotiates the format when the template asks it to. Width and height are the author's, since there is no file to read. A string with no source is an <img src> as written.

A font is a file under fonts/

Put the faces under app/fonts/ and name the family:

toml
[fonts.sans]
family = "Inter"
fallback = "Arial"
css
body { font-family: var(--font-sans); }

The build reads each file's own tables for its weight and style, writes the @font-face rules and defines --font-sans as "Inter", "Inter Fallback", sans-serif. Inter Fallback is Arial drawn with size-adjust and the three overrides computed from the two faces' metrics, so a line of Arial takes the room the same line of Inter will. The text is laid out once in the fallback and the swap moves nothing. The regular weight is preloaded; the rest arrive when the CSS asks for them.

Every page carries the CSS inline, hashed into style-src, the preloads and the files under their hashed names. The host writes all of it; a layout has nothing to add.

To take a family from Google Fonts, fetch it once:

sh
fsr fonts add app google:Inter@400,700

Each subset the provider serves lands as a file with a .range sidecar. When the file's own name table calls the family something else, as Bricolage Grotesque's does after its default optical size, a .family sidecar keeps the provider's name, so family = "Bricolage Grotesque" still finds it. A family the provider holds as a variable font comes as one file per subset whatever weights you asked for; the build declares it at the file's own weight axis, font-weight: 100 900, so every weight in between is yours too. The fallback is sized from the Latin subset, since a Vietnamese or Cyrillic one has no a to z to measure. The build serves them as local files from then on. The build itself never reaches the network. The other shape is a provider's own stylesheet, linked rather than served:

toml
[fonts.display]
family = "Fraunces"
remote = "https://fonts.googleapis.com/css2?family=Fraunces&display=swap"

That key gets no fallback metrics, since there is no file to read, so its text reflows when the face arrives. Every visitor's IP goes to the provider on every page. Vendor when you can.

A video or an audio file

A video is a plain <video> element. Import its files and the build serves each as it is under a content hash, the way it serves a font:

tsx
import walkMp4 from "@src/media/walk.mp4";
import walkWebm from "@src/media/walk.webm";
import walkCaptions from "@src/media/walk.vtt";
import walkPoster from "@src/media/walk-poster.png";

<video controls preload="metadata" playsInline width={480} height={270} poster={walkPoster.src}>
  <source src={walkWebm} type="video/webm" />
  <source src={walkMp4} type="video/mp4" />
  <track kind="captions" src={walkCaptions} srcLang="en" label="English" default />
</video>

The conference app's venue page has this one. The import is the URL, so it works the same in a lowered component and in an island. mp4, m4v, webm, ogv and mov are video, mp3, m4a, aac, oga, ogg, opus and wav are audio and vtt is a caption track. The host answers a Range request with 206, which a browser needs to seek and Safari needs to play at all. Nothing is transcoded and no poster is cut from the file: encode the sources you want, WebM first and MP4 as the fallback. The poster is an image you give.

Set width and height so the page does not move when the video loads. Set preload="metadata" so a visitor who never plays it does not download it. A video that plays on its own needs muted and playsInline as well as autoPlay, since browsers refuse to autoplay sound. A src written as a literal URL on another origin, a CDN or a hosting service, is added to the policy's media-src.

A video hosted elsewhere

A YouTube or Vimeo video is an Embed. The server renders the provider's still with the title and a play button. The player's iframe replaces it when the visitor clicks:

tsx
import { Embed } from "@snapfire/fsr-client/react";

<Embed provider="youtube" id="f_WuRfuMXQw" title="Rick Astley - Never Gonna Give You Up" />

provider is a string written out, since the page's policy names the hosts it reaches. id is a string written out or a value the page computes, such as a field of its loader's data. title is required, since it names the video on the placeholder, to a screen reader and on the player. The placeholder keeps a 16:9 box, so nothing moves when it loads or plays; place it inside an element as wide as you want it and style that element through className. The still loads from the provider by default. [media] embed_stills = "local" fetches it at build and serves it from your origin instead, for an id written out, which chapter 306 explains along with everything else a page asks other hosts for. An Embed renders on the server, so place it in a page, a layout or a component that does not hydrate: the build refuses one inside a component the browser hydrates, which has no still to render it with.

A card image beside a route

A link pasted into a chat or a post shows a card. The card's picture is the page's og:image. Put the picture beside the route it stands for:

text
routes/
  opengraph-image.png            every page's card
  blog/
    opengraph-image.png          the blog's, replacing it under /blog
    opengraph-image.alt.txt      its alt text
    twitter-image.png            a separate picture for X's card

The build serves each as its hashed original with no resized variant, since a crawler fetches one URL. It strips the metadata as [images] strip says. It writes og:image with its width, height and type and og:image:alt from the text file. A twitter-image gets the same rows under twitter:image. X reads the Open Graph image when there is no twitter-image, so one file is usually enough. A page sets its own card type with twitter("card", "summary_large_image") in its meta.

An image covers every route under its directory. A nearer one replaces it. The file wins over an og("image", ...) a meta returns, as it does in Next. A crawler reads the URL as absolute only, so the host writes it against document.origin; set that, or the tag carries a path the card ignores. PNG, JPEG, GIF and WebP are accepted, since those are what the crawlers decode. The build refuses a directory with two pictures of one kind and a picture with no route under it.

A card image drawn from the page's data

A page whose card should show its own title draws the picture with a component. Put opengraph-image.tsx beside the page:

tsx
import type { PostSlugProps } from "@generated/client";

export const size = { width: 1200, height: 630 };
export const alt = "A post on the Acme blog";

export default function Image({ post }: PostSlugProps) {
  return (
    <div style={{ display: "flex", flexDirection: "column", justifyContent: "space-between", width: "100%", height: "100%", padding: 72, backgroundColor: "#fdf6e3" }}>
      <span style={{ fontSize: 30, fontWeight: 700 }}>Acme blog</span>
      <h1 style={{ fontSize: 72, margin: 0, lineClamp: 2 }}>{post.title}</h1>
    </div>
  );
}

The component's props are the page's: its loader's data with params. The page's head gets og:image at <the page's path>/opengraph-image.png, absolute against document.origin, with the width, height, type and alt. The host draws the image when that URL is asked for: it runs the page's loader for the params, renders the component and draws the markup. A loader that answers not found answers the image with a 404 too. twitter-image.tsx is the same under twitter:image.

size defaults to 1200 by 630. contentType is "image/png" unless it says "image/jpeg". alt can also come from opengraph-image.alt.txt. All three are literals, since the build reads them without running anything.

The markup is styled inline, with the CSS a card uses: flexbox, absolute positioning, sizes, borders and radii, gradients, shadows, transforms, opacity and the text properties. display defaults to flex, as in Next's ImageResponse, so a card written for Next draws the same. A property outside that, display: grid say, is left out with a warning in the log. A value that is not CSS fails the image with a 500 naming it. Text draws with the faces under fonts/, then with a bundled Inter that covers Latin. Text no face covers is left out and logged. A [fonts.<key>] entry with scripts draws text in those scripts with its faces before any other, so a card with Japanese titles names a Japanese face once:

toml
[fonts.jp]
family = "Noto Sans JP"
scripts = ["Han", "Hiragana", "Katakana"]

The names are Unicode's; one Unicode does not have refuses the boot. An <img> takes a data: URI or an image the build placed, by its imported URL. Nothing is fetched from another origin.

An image beside a page, a layout or in a directory with no page covers every page under it, as a file does. A nearer image (a file or a component) replaces it. Its props are the data of the page beside it with params. With no page there, they are the layout's. On a page under it, that loader runs for the params the two share, so a card beside post/[slug]/page.tsx draws /post/a/comments from the post's data. An image that needs data of its own reads it from opengraph-image.loader.ts beside it, a loader like any other. That loader's data are its props on every page it covers:

ts
import { posts } from "@src/posts";

export async function load() {
  return { count: posts.length, latest: posts[0].title };
}

The build generates the component's props type from it, named for the directory and the image as a page's is: RootOpengraphImageProps here, PostSlugTwitterImageProps for a twitter-image.loader.ts under post/[slug]/.

tsx
import type { RootOpengraphImageProps } from "@generated/client";

export default function Image({ count, latest }: RootOpengraphImageProps) {
  return <h1>{count} posts, the latest {latest}</h1>;
}

A page with several cards lists them with images (Next calls it generateImageMetadata). It takes the context and returns an entry per image, each with an id and, where it differs from the module's, an alt, a size or a contentType:

tsx
export function images({ params }: { params: { id: string } }) {
  return [
    { id: "total", alt: `Report ${params.id}: the total` },
    { id: "rows", size: { width: 1200, height: 900 } },
  ];
}

export default function Image({ report, id }: IdProps & { id: string }) {
  return id === "total" ? <h1>{report.total}</h1> : <ul>{/* the rows */}</ul>;
}

Each is named in the head in order, at <the page's path>/opengraph-image/<id>. Each is drawn with its id among the props. An id the list does not name is a 404.

The component draws once with no browser, so it holds no state, places no island and reads nothing its data does not hand it. The build refuses one that does. It also refuses one with no page under it. A file and a component for the same card in one directory is refused too. In development the image is drawn on every request; otherwise the host keeps the drawn image for its params and data. fsr prerender writes each prerendered page's images beside its document as opengraph-image.png or opengraph-image/<id>. Production answers from those files while they are the build's.

A CDN in front

A deployment whose static tree a CDN serves sets the base at build time, since the URLs are written into the markup:

toml
[images]
base = "https://cdn.example.com"

[fonts]
base = "https://cdn.example.com"

The host widens the policy with the origin. The CDN has to send Access-Control-Allow-Origin for the fonts, since the browser fetches a font across origins in CORS mode whatever the page says. fsr bundle writes the files under serve/; getting them onto the CDN is the deployment's job.

Where things live

Every directory is a convention, moved with one table:

toml
[dirs]
styles = "assets/css"
fonts = "assets/fonts"
images = "assets/images"
icons = "assets/icons"

The routes stay what they are. styles, fonts and icons are scanned; images is the one directory a template names a file under, since a component finds its images by import wherever they sit.

The lab

Every path this chapter names is on one of the two pages of gallery_react_ts, each row of its README naming the path it exercises; cargo test -p gallery_react_ts asserts the markup for each. The storefront below is the production-shaped one.

Run the storefront and open the catalog with the network panel on. Fourteen cards fetch fourteen AVIF files at 640 wide, 31 KB in all, against 200 KB for the originals. The first card's file was requested by the preload before the stylesheet. Narrow the window to a phone and reload: the same cards fetch the 960 variants, since the slot is full width on a two-pixel-per-CSS-pixel screen. Block *.woff2 in the panel and reload: every heading and price sits where it did with Inter, since the fallback took the same room. Then change widths in config/app.toml to [320], save and watch the report say derived 28 files under dist/ once and 0 on the save after.