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.
Defining an OG route
Create a file in server/og/ that exports defineOG() with a path pattern and a generate function:
// 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/radioheadserves the generated PNGGET /artist/radiohead(the page route) automatically getsog:imagemeta 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:
{
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 for supported CSS properties.
Using fetchImage
Remote images must be converted to base64 data URLs before Satori can render them:
const artUrl = await ctx.fetchImage('https://example.com/image.jpg')
// Returns "data:image/jpeg;base64,..." or null on failureThen 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.