Skip to content

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:

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:

FieldDescription
db.query(sql, params?)Run SQL queries against SQLite
paramsURL 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:

FieldRequiredDescription
elementYesA Satori virtual DOM tree
optionsNoOverride width (default 1200), height (default 630), or provide custom fonts
metaNotitle 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 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.