Tutorial 10 of 36 · for anyone whose links get pasted into chats and posts · Markdown

Add share cards

You'll give each page of a small recipe site its own share card: the picture a chat app or a social site shows when someone pastes the link. Each card is drawn from the page's own data by the server that serves the page.

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 a card is#

When a link is pasted, the site reading it fetches the page and looks for og:image in the head. That URL is the card. Here a component beside the route draws one for each page, with the same props the page gets.

Start with a recipe page#

$ fsr new cards --with react

Some recipes to draw, in app/src/recipes.ts:

app/src/recipes.ts
export type Recipe = { slug: string; title: string; minutes: number; serves: number; cook: string };

export const RECIPES: Recipe[] = [
  { slug: "miso-soup", title: "Miso soup in ten minutes", minutes: 10, serves: 2, cook: "Aiko" },
  { slug: "shakshuka", title: "Shakshuka for a slow Sunday", minutes: 35, serves: 4, cook: "Dani" },
  { slug: "dal", title: "Weeknight red lentil dal", minutes: 25, serves: 4, cook: "Priya" },
];

The recipe route's loader finds the recipe or fails with a 404:

app/routes/recipe/[slug]/page.loader.ts
import type { Ctx } from "@snapfire/fsr";
import { fail } from "@snapfire/fsr";
import { RECIPES } from "@src/recipes";

export async function load({ params }: Ctx<"/recipe/{slug}">) {
  const recipe = RECIPES.find((r) => r.slug === params.slug);
  if (!recipe) fail("not_found", `there is no recipe ${params.slug}`);
  return { recipe };
}

export const meta = ({ data }: { data: { recipe: { title: string } } }) => ({
  title: data.recipe.title,
});
app/routes/recipe/[slug]/page.tsx
import type { RecipeSlugProps } from "@generated/client";

export default function Recipe({ recipe }: RecipeSlugProps) {
  return (
    <article>
      <h1>{recipe.title}</h1>
      <p>
        {recipe.minutes} minutes, serves {recipe.serves}. From {recipe.cook}.
      </p>
    </article>
  );
}

Name the origin#

A site reading og:image only follows an absolute URL. Tell the host which origin this deployment answers on, under [document] in config/app.toml, below title:

config/app.toml
[document]
title = "cards"
origin = "http://127.0.0.1:3000"

In production this is your real domain, https://cards.example. The value is the scheme and the host only; a trailing slash is refused at boot.

Draw the card#

Put opengraph-image.tsx beside the page:

app/routes/recipe/[slug]/opengraph-image.tsx
import type { RecipeSlugProps } from "@generated/client";

export const size = { width: 1200, height: 630 };
export const alt = "A recipe card";

export default function Image({ recipe }: RecipeSlugProps) {
  return (
    <div
      style={{
        flexDirection: "column",
        justifyContent: "space-between",
        width: "100%",
        height: "100%",
        padding: 72,
        backgroundColor: "#fff7ed",
        color: "#431407",
      }}
    >
      <span style={{ fontSize: 32, fontWeight: 700, color: "#c2410c" }}>cards</span>
      <h1 style={{ fontSize: 76, fontWeight: 700, lineHeight: 1.1, margin: 0 }}>{recipe.title}</h1>
      <span style={{ fontSize: 36 }}>
        {recipe.minutes} min · serves {recipe.serves} · by {recipe.cook}
      </span>
    </div>
  );
}

The props are the page's: RecipeSlugProps, the loader's data. size and alt are literals because the build reads them without running anything. Every element is a flex box unless it says otherwise, the same default Next's ImageResponse uses, so a card written for Next draws the same here.

Start the server:

$ fsr dev cards/app

Look at what a crawler sees#

$ curl -s http://127.0.0.1:3000/recipe/shakshuka | grep -o '<meta property="og:image[^>]*>'
<meta property="og:image" content="http://127.0.0.1:3000/recipe/shakshuka/opengraph-image.png?v=f0b9bb07">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:type" content="image/png">
<meta property="og:image:alt" content="A recipe card">

The build saw the component beside the page and the host wrote those tags with the size, type and alt, against the origin you named. The v changes when the card's component changes, so a crawler that cached the old design fetches the new one after a deploy. It doesn't follow the data: every recipe carries the same v and a renamed recipe keeps it.

Open http://127.0.0.1:3000/recipe/shakshuka/opengraph-image.png in the browser. It's a 1200 by 630 card in cream and rust: "cards" small at the top, "Shakshuka for a slow Sunday" large in the middle and "35 min · serves 4 · by Dani" along the bottom. Change the URL to /recipe/dal/opengraph-image.png and the same component draws the dal.

A recipe that doesn't exist has no card either:

$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/recipe/nope/opengraph-image.png
404

The host ran the page's loader to draw the card, so the loader's fail("not_found", ...) answers the image too.

One card for everything else#

The index has no card yet. An image beside a layout covers every page under it and a nearer one replaces it. So a card in app/routes/ covers the index while each recipe keeps its own. It needs the recipe count, which the index page doesn't load, so it gets a loader of its own beside it:

app/routes/opengraph-image.loader.ts
import { RECIPES } from "@src/recipes";

export async function load() {
  return { count: RECIPES.length };
}
app/routes/opengraph-image.tsx
import type { RootOpengraphImageProps } from "@generated/client";

export const alt = "The cards recipe box";

export default function Image({ count }: RootOpengraphImageProps) {
  return (
    <div
      style={{
        flexDirection: "column",
        justifyContent: "center",
        width: "100%",
        height: "100%",
        padding: 72,
        backgroundColor: "#431407",
        color: "#fff7ed",
      }}
    >
      <h1 style={{ fontSize: 96, fontWeight: 700, margin: 0 }}>cards</h1>
      <span style={{ fontSize: 40 }}>{count} recipes, each with its own card</span>
    </div>
  );
}

RootOpengraphImageProps is generated from what that loader returns, the way a page's props are from its own. size is left out, so it's the default 1200 by 630. Check both pages:

$ curl -s http://127.0.0.1:3000/ | grep -o '<meta property="og:image"[^>]*>'
<meta property="og:image" content="http://127.0.0.1:3000/opengraph-image.png?v=67621563">
$ curl -s http://127.0.0.1:3000/recipe/dal | grep -o '<meta property="og:image"[^>]*>'
<meta property="og:image" content="http://127.0.0.1:3000/recipe/dal/opengraph-image.png?v=f0b9bb07">

/opengraph-image.png is a dark card reading "cards" and "3 recipes, each with its own card". Add a fourth recipe to RECIPES and reload it: it says 4.

Draw them once, at build#

In development the host draws a card on every request, so an edit shows up on reload. In production a page that prerenders gets its card written beside it. The recipe route needs paths to prerender, as in 110. Add it as the last line of app/routes/recipe/[slug]/page.loader.ts:

app/routes/recipe/[slug]/page.loader.ts
export const paths = () => RECIPES.map((r) => ({ slug: r.slug }));

Then point [server] in config/app.toml at a directory, below listen:

config/app.toml
[server]
listen = "127.0.0.1:3000"
prerender = "dist/prerender"

Stop fsr dev, then build and prerender:

$ fsr build cards/app
$ fsr prerender cards/app
loads.json             cards/app/dist/prerender/loads.json
/                      cards/app/dist/prerender/index.html
/opengraph-image.png   cards/app/dist/prerender/opengraph-image.png
/                      cards/app/dist/prerender/index.payload
/recipe/miso-soup      cards/app/dist/prerender/recipe/miso-soup/index.html
/recipe/miso-soup/opengraph-image.png cards/app/dist/prerender/recipe/miso-soup/opengraph-image.png
/recipe/miso-soup      cards/app/dist/prerender/recipe/miso-soup/index.payload
/recipe/shakshuka      cards/app/dist/prerender/recipe/shakshuka/index.html
/recipe/shakshuka/opengraph-image.png cards/app/dist/prerender/recipe/shakshuka/opengraph-image.png
/recipe/shakshuka      cards/app/dist/prerender/recipe/shakshuka/index.payload
/recipe/dal            cards/app/dist/prerender/recipe/dal/index.html
/recipe/dal/opengraph-image.png cards/app/dist/prerender/recipe/dal/opengraph-image.png
/recipe/dal            cards/app/dist/prerender/recipe/dal/index.payload
prerender.build        cards/app/dist/prerender/prerender.build
prerendered.json       cards/app/dist/prerender/prerendered.json

The fsr client modules the pages load are written alongside under static/js/fsr/; they're left out above. Every card is a file now, so production serves it from disk. A recipe added between deploys still gets a card, drawn on request until the next prerender.

Try it#

Give the recipe card's outer div a width: "wide". The image answers 500 and the body names the element and the value: the image did not draw: div(1): `width: wide` does not parse: `wide` is not a length. A value that isn't CSS fails the card rather than drawing something you didn't ask for.

Then add a twitter-image.tsx beside the recipe page with a different layout. The head gains twitter:image rows for it while og:image keeps the first card. X reads og:image when there's no twitter-image, so you only need the second file when the two should differ.