--- title: OpenGraph Images description: Generate dynamic OpenGraph images for link previews. --- Hatk generates dynamic OpenGraph images so your pages get rich previews when shared. You define a `generate` function that returns a virtual DOM tree, and Hatk renders it to a 1200x630 PNG using [Satori](https://github.com/vercel/satori). ## Defining an OG route Create a file in `server/og/` that exports `defineOG()` with a path pattern and a generate function: ```typescript // server/og/artist.ts import { defineOG } from '$hatk' export default defineOG('/og/artist/:artist', async (ctx) => { const { db, params, fetchImage } = ctx const rows = await db.query( `SELECT CAST(COUNT(*) AS INTEGER) AS play_count FROM "fm.teal.alpha.feed.play__artists" WHERE artist_name = ?`, [params.artist], ) const stats = rows[0] || { play_count: 0 } return { element: { type: 'div', props: { style: { display: 'flex', width: '100%', height: '100%', background: '#070a11', color: 'white', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', }, children: [ { type: 'div', props: { children: params.artist, style: { fontSize: 58, fontWeight: 700 } } }, { type: 'div', props: { children: `${stats.play_count} plays`, style: { fontSize: 28, color: '#94a3b8', marginTop: '16px' } } }, ], }, }, meta: { title: params.artist }, } }) ``` ## How it works The `path` field uses Express-style route parameters. The `/og` prefix is significant: - `GET /og/artist/radiohead` serves the generated PNG - `GET /artist/radiohead` (the page route) automatically gets `og:image` meta tags injected pointing to the OG image URL This keeps page routes and OG routes in sync. You don't need to add meta tags manually. ## Generate context The `generate` function receives an `OpengraphContext` with: | Field | Description | | --- | --- | | `db.query(sql, params?)` | Run SQL queries against SQLite | | `params` | URL path parameters (e.g. `{ artist: 'Radiohead' }`) | | `fetchImage(url)` | Fetch a remote image and return it as a base64 data URL for use in `img` elements | | `lookup(collection, field, values)` | Look up records by field value | | `count(collection, field, values)` | Count records by field value | | `labels(uris)` | Query labels for record URIs | | `blobUrl(did, cid)` | Resolve a blob reference to a URL | ## Return value Return an `OpengraphResult`: | Field | Required | Description | | --- | --- | --- | | `element` | Yes | A Satori virtual DOM tree | | `options` | No | Override `width` (default 1200), `height` (default 630), or provide custom `fonts` | | `meta` | No | `title` and `description` for the injected meta tags | ## Virtual DOM Satori uses a React-like virtual DOM. Elements are plain objects with `type` and `props` containing `style` and `children`: ```typescript { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', gap: '16px' }, children: [ { type: 'div', props: { children: 'Hello', style: { fontSize: 48 } } }, { type: 'img', props: { src: imageDataUrl, width: 300, height: 300 } }, ], }, } ``` All layouts must use `display: 'flex'`. See the [Satori docs](https://github.com/vercel/satori#css) for supported CSS properties. ## Using `fetchImage` Remote images must be converted to base64 data URLs before Satori can render them: ```typescript const artUrl = await ctx.fetchImage('https://example.com/image.jpg') // Returns "data:image/jpeg;base64,..." or null on failure ``` Then pass it as the `src` on an `img` element. ## Caching Generated images are cached in memory for 5 minutes (up to 200 entries). A default Inter font is bundled; custom fonts can be provided via `options.fonts`.