---
title: "Add share cards"
audience: "anyone whose links get pasted into chats and posts"
description: "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…"
snippet_caption: ""
snippet: ""
---

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

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.

<Install captured="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

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

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

```ts title="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:

```ts title="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,
});
```

```tsx 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`:

```toml title="config/app.toml" {3}
[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:

```tsx title="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:

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

## Look at what a crawler sees

```shell-session
$ 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:

```shell-session
$ 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:

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

export async function load() {
  return { count: RECIPES.length };
}
```

```tsx title="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:

```shell-session
$ 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](https://snapfirers.com/fsr/learn/110-prerender-a-blog-at-build-time). Add it as the last line of `app/routes/recipe/[slug]/page.loader.ts`:

```ts title="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`:

```toml title="config/app.toml" {3}
[server]
listen = "127.0.0.1:3000"
prerender = "dist/prerender"
```

Stop `fsr dev`, then build and prerender:

```shell-session
$ 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.
