Hono OG Middleware: Generate Dynamic OG Images at the Edge

Build a Hono middleware that generates OG images at the edge using Satori. Covers Cloudflare Workers, Deno Deploy, and Bun with full code examples.

Why Hono for OG image generation

Hono is a lightweight web framework that runs everywhere — Cloudflare Workers, Deno Deploy, Bun, Node.js, and AWS Lambda. Its tiny footprint and edge-first design make it perfect for OG image generation endpoints that need to be fast and globally distributed.

By building OG image generation as a Hono middleware, you can add dynamic social share images to any Hono-based project with minimal code. The middleware intercepts requests to /og/* routes and returns generated PNG images.

Basic Hono OG image route

// src/index.ts
import { Hono } from 'hono';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

const app = new Hono();

app.get('/og/:title', async (c) => {
  const title = decodeURIComponent(c.req.param('title'));
  
  const svg = await satori(
    {
      type: 'div',
      props: {
        style: {
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          background: 'linear-gradient(135deg, #0a0a0a, #1a1a2e)',
          width: '100%',
          height: '100%',
          padding: '60px',
        },
        children: [
          {
            type: 'div',
            props: {
              style: {
                fontSize: 64,
                fontWeight: 900,
                color: '#ffffff',
                lineHeight: 1.2,
              },
              children: title,
            },
          },
          {
            type: 'div',
            props: {
              style: {
                fontSize: 24,
                color: '#8b5cf6',
                marginTop: 'auto',
              },
              children: 'mysite.com',
            },
          },
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Inter',
          data: await loadFont(), // Your font loading logic
          weight: 900,
          style: 'normal',
        },
      ],
    }
  );

  const png = new Resvg(svg).render().asPng();
  
  return c.body(png, 200, {
    'Content-Type': 'image/png',
    'Cache-Control': 'public, max-age=86400, s-maxage=604800',
  });
});

export default app;

Building it as reusable middleware

Extract the OG generation logic into a reusable middleware factory:

// middleware/og.ts
import { createMiddleware } from 'hono/factory';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

interface OgOptions {
  brandName: string;
  brandColor: string;
  fontData: ArrayBuffer;
}

export function ogMiddleware(options: OgOptions) {
  return createMiddleware(async (c, next) => {
    // Only handle /og/* routes
    if (!c.req.path.startsWith('/og/')) {
      return next();
    }

    const params = new URL(c.req.url).searchParams;
    const title = params.get('title') || 'Untitled';
    const subtitle = params.get('subtitle') || '';

    const svg = await satori(
      {
        type: 'div',
        props: {
          style: {
            display: 'flex',
            flexDirection: 'column',
            justifyContent: 'center',
            background: '#0a0a0a',
            width: '100%',
            height: '100%',
            padding: '60px',
          },
          children: [
            {
              type: 'div',
              props: {
                style: { fontSize: 56, fontWeight: 900, color: '#fff' },
                children: title,
              },
            },
            subtitle && {
              type: 'div',
              props: {
                style: { fontSize: 28, color: '#a1a1aa', marginTop: 16 },
                children: subtitle,
              },
            },
            {
              type: 'div',
              props: {
                style: {
                  fontSize: 24,
                  color: options.brandColor,
                  marginTop: 'auto',
                },
                children: options.brandName,
              },
            },
          ].filter(Boolean),
        },
      },
      {
        width: 1200,
        height: 630,
        fonts: [{ name: 'Inter', data: options.fontData, weight: 900 }],
      }
    );

    const png = new Resvg(svg).render().asPng();
    return c.body(png, 200, {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=86400',
    });
  });
}
// src/index.ts — using the middleware
import { Hono } from 'hono';
import { ogMiddleware } from './middleware/og';

const app = new Hono();

app.use('/*', ogMiddleware({
  brandName: 'My SaaS',
  brandColor: '#8b5cf6',
  fontData: await fetch('https://...').then(r => r.arrayBuffer()),
}));

// Your regular routes
app.get('/', (c) => c.html('<h1>Home</h1>'));

// OG images are now available at:
// /og/?title=My+Blog+Post&subtitle=A+guide+to+something
export default app;

Deploying to Cloudflare Workers

For Cloudflare Workers, you'll need @resvg/resvg-wasm instead of the Node.js native module:

// wrangler.toml
name = "og-generator"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[build]
command = "npm run build"

# Bundle font file as static asset
[[rules]]
type = "Data"
globs = ["**/*.ttf"]
import { initWasm, Resvg } from '@resvg/resvg-wasm';
import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';

let initialized = false;

app.get('/og', async (c) => {
  if (!initialized) {
    await initWasm(resvgWasm);
    initialized = true;
  }
  // ... rest of generation logic
});

Reference OG images in your HTML

<meta property="og:image" content="https://mysite.com/og/?title=My+Post+Title" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://mysite.com/og/?title=My+Post+Title" />

Test your Hono-generated OG images

After deploying your Hono OG middleware, verify the images render correctly on every platform with OGFixer.

Check your OG images →