# OG images

> One 1200×630 PNG per listing and category, rendered at build time in your theme colours, the static fallback, and what happens when a render fails.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/configure/og-images/

---

Every listing and category page gets its own 1200×630 Open Graph image, rendered during
`pnpm build` from the listing's name, tagline, logo and category in your theme's colours.
`seo.ogImages: 'generated'` is the default; `'static'` points every page at `public/og-default.png`
instead.

> **Free edition**
>
> Static only: the renderer, the fonts and `pnpm og:default` are not in that edition, and
> `seo.ogImages` is fixed to `'static'`.

## Setup

1. Leave `seo.ogImages` on `'generated'`, or set it.

   ```ts title="site.config.ts"
   seo: {
     ogImages: 'generated',
     ogScope: { listings: true, categories: true, pseo: false },
   },
   ```

   `pnpm check:config` answers `site.config.ts OK — <name> (<url>); …`.

2. Regenerate the fallback. It is the image the home page, the tag pages and every failed render
   use, and the shipped file carries the demo's name.

   ```sh title="Terminal"
   pnpm og:default
   ```

   ```text
   public/og-default.png: 1200×630, N bytes — "<site.name>" (<site.tagline>)
   ```

   `pnpm og:default --check` renders to memory and compares; it exits 1 with
   `public/og-default.png is out of date — run: pnpm og:default` when the file is stale, which
   makes it a CI step.

3. Build and read the `og:` lines.

   ```sh title="Terminal"
   pnpm build
   ```

   ```text
   og: N images to produce (L listings, C categories, 0 pseo, 1 default)
   og: rendered N images in S s (cache hits H)
   [zerodirs:og] engine=native, rendered=R, fallbacks=0, logos … / satori … / resvg …; cache=node_modules/.cache/zerodirs/og/ (…)
   ```

   `fallbacks=0` is the number to look at.

4. Open one image: `https://<your domain>/og/<listingBase>/<slug>.png` — on the demo,
   `/og/tools/chatgpt.png`.

   ![A listing's OG image as served: the logo tile, the category, the name, the tagline and the site name in the theme colours](../../../assets/screenshots/og-example.png)

## Verify

| | Where | You should see |
| --- | --- | --- |
| The count | the build output | `og: N images to produce (…)`, `N` being listings plus categories plus one, then `fallbacks=0` |
| The image | `https://<your domain>/og/<listingBase>/<slug>.png` | a 1200×630 PNG with that listing's name and logo |
| The tag | `view-source:https://<your domain>/<listingBase>/<slug>/` | `<meta property="og:image" content="https://<your domain>/og/<listingBase>/<slug>.png">`, with `og:image:width` 1200 |
| The fallback | `pnpm og:default --check` | `public/og-default.png is up to date (N bytes, "<site.name>")` |

## How it works

The images are files, not a runtime. A prerendered endpoint at `src/pages/og/` lists every image
the scope calls for and renders each one during the build; nothing renders on request.

| Page | Image | Switch |
| --- | --- | --- |
| a listing | `/og/<listingBase>/<slug>.png`: logo tile or initial letter, category, name, tagline | `ogScope.listings`, on |
| a category, page 1 | `/og/<categoryBase>/<slug>.png`: name, description, listing count; later pages reuse it | `ogScope.categories`, on |
| a generated page | `/og/<slug>.png`: the h1 and the count | `ogScope.pseo`, off |
| everything else: home, indexes, tags, blog, fixed pages | `/og/default.png`, from `site.name` and `site.description` | always |

Each image is a satori element tree, set in Inter Regular and Bold from `src/assets/fonts/og/`,
turned into SVG and rasterised to PNG by resvg. The native `@resvg/resvg-js` addon is the default;
`OG_RENDERER=wasm` in `.env` selects `@resvg/resvg-wasm`, slower but with no binary to load. It is
a [build-time variable](https://zerodirs.com/docs/deploy/environment/#build-time-variables), read from the environment,
never from `wrangler.jsonc`. Colours come from the preset's hex `ogColors` block, because satori
cannot read `oklch()`; a hex `theme.tokens.primary` replaces the preset's primary, and any other
override is ignored here.

Remote logos are fetched ahead of their render, eight at a time, and SVG logos are rasterised at
256 px before they are embedded. Finished PNGs are cached in `node_modules/.cache/zerodirs/og/`
under a hash of the template version and every visible field, so a local rebuild only re-renders
what changed. Workers Builds does not keep that directory, so a git-push deploy re-renders every
image on every build. A logo file edited in place keeps its hash; delete the cache directory to
force a render.

## When it is off or degraded

| Config state | The visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `seo.ogImages: 'static'` | `public/og-default.png` on every page; the endpoint emits nothing | nothing | expected; run `pnpm og:default` so the file carries your name |
| a render fails (satori or resvg throws) | that page's `og:image` serves the fallback bytes; the build goes on with `[zerodirs:og] <path>: render failed (…); serving public/og-default.png instead` and `fallbacks=N` | nothing | read the message, fix the cause, rebuild; a failed render is not cached |
| the native addon does not load | the same images, rendered slower, after `[zerodirs:og] @resvg/resvg-js could not be loaded (…); falling back to @resvg/resvg-wasm (set OG_RENDERER=wasm to silence)` | nothing | set `OG_RENDERER=wasm` in `.env` |
| a font file is missing | every image falls back: `render failed (og: font <path> is missing …)` per image, `fallbacks=N`; `pnpm og:default` refuses to write | nothing | restore `src/assets/fonts/og/` |
| `public/og-default.png` is missing | under `'static'` every `og:image` is a 404; under `'generated'` the first failed render stops the build with `og: fallback image <file> is missing (…)` | nothing | `pnpm og:default` |
| `seo.ogScope.pseo: true` | one PNG per generated page, each a file against the plan limit | nothing | turn it off first when the budget warns |
| the free edition | the static image everywhere | nothing | the paid starter |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `seo.ogImages` | `'generated'` | `'static'` uses `public/og-default.png` everywhere and emits nothing |
| `seo.ogScope.listings` | `true` | one image per listing |
| `seo.ogScope.categories` | `true` | one image per category |
| `seo.ogScope.pseo` | `false` | one image per generated page |
| `theme.preset` | `'default'` | the `ogColors` hex block the image uses |
| `theme.tokens.primary` | unset | a hex value replaces the preset's primary in the image; an `oklch()` value does not |

See [`seo`](https://zerodirs.com/docs/configure/reference/#seo) and [`theme`](https://zerodirs.com/docs/configure/reference/#theme).

## Files

- src/
  - pages/og/ the prerendered endpoint: one route per image, the `og:` count line
  - features/paid/og/
    - template.ts the satori layout and `OG_TEMPLATE_VERSION`
    - render.ts satori, resvg, the native-to-wasm fallback, the render-failed fallback
    - specs.ts which pages get an image and what goes on it
    - cache.ts the content-hash cache
    - fonts.ts the Inter faces
  - assets/fonts/og/ Inter Regular and Bold, TTF
- scripts/og-default.ts `pnpm og:default` and `--check`
- public/og-default.png the fallback and the static image

## Related

- [SEO and the report](https://zerodirs.com/docs/configure/seo/) — The head tags the image is referenced from, and the build report.
- [Logos and media](https://zerodirs.com/docs/configure/logos-and-media/) — Where a listing's logo comes from; it is what the image embeds.
- [Programmatic SEO](https://zerodirs.com/docs/configure/pseo/) — The generated pages, and what seo.ogScope.pseo adds to the file budget.
- [Environment and secrets](https://zerodirs.com/docs/deploy/environment/) — OG_RENDERER and the other build-time variables.
