Skip to content
Back to blog
May 06, 2026
8 min read
-- views

Per-Post OG Images, Last-Updated Dates, and a Roadmap

Generating dynamic Open Graph images per blog post with Sharp, adding optional last-updated dates to the post schema, and starting a roadmap doc to track future work.
Share

This round of changes is mostly about polish: every blog post now ships its own Open Graph image, posts can carry an optional “last updated” date, and there’s a new roadmap doc to track everything I want to come back to. I also fixed a couple of latent issues — the site URL still pointed at the template demo, and the view-counter API spat stack traces in local dev when Vercel KV wasn’t configured.

Per-post Open Graph images

A single static open-graph.jpg is fine for the homepage but boring everywhere else. Social cards are one of the few places the title and tone of a post can grab someone’s attention before the click, so it’s worth doing properly.

The constraint I set: no new dependencies. The blog already pulls in Sharp for image optimization, and Sharp 0.33 added a text input source backed by Pango. That turns out to be enough for a clean editorial layout — title, summary, byline, avatar — without reaching for satori or @vercel/og.

A static endpoint per slug

The endpoint lives at src/pages/og/[slug].png.ts and uses getStaticPaths so every post gets a PNG generated at build time. There is zero runtime cost — Vercel just serves the prerendered files.

// src/pages/og/[slug].png.ts
export async function getStaticPaths() {
  const posts = await getCollection("blog")
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }))
}

The handler itself renders four pieces of text with Sharp’s text: input source, then composites them onto a solid background:

async function renderText(opts: {
  text: string
  font: string
  width: number
  height: number
}): Promise<Buffer> {
  return await sharp({
    text: {
      text: opts.text,
      font: opts.font,
      width: opts.width,
      height: opts.height,
      rgba: true,
      align: "left",
      wrap: "word",
    },
  })
    .png()
    .toBuffer()
}

The text field accepts Pango markup, which is what unlocks per-span colors and letter-spacing without any extra plumbing:

const eyebrowMarkup = `<span foreground="#737373" letter_spacing="2000">${escapePango(eyebrowText)}</span>`
const titleMarkup = `<span foreground="#0a0a0a">${escapePango(title)}</span>`

Composite, dump as PNG, return.

const png = await sharp({
  create: {
    width: 1200,
    height: 630,
    channels: 4,
    background: { r: 250, g: 250, b: 249, alpha: 1 },
  },
})
  .composite(composites)
  .png()
  .toBuffer()

return new Response(new Uint8Array(png), {
  headers: {
    "Content-Type": "image/png",
    "Cache-Control": "public, max-age=31536000, immutable",
  },
})

Wiring it into BaseHead

BaseHead.astro already accepted an optional image prop with a default fallback, so threading the per-post image through PageLayout was a two-line change in the blog page route:

---
const { title, summary, date, updated, tags } = post.data
---

<PageLayout
  title={title}
  description={summary}
  image={`/og/${post.slug}.png`}
  article={{ publishedTime: date, modifiedTime: updated, tags }}
/>

Non-post pages keep using /open-graph.jpg. Done.

Don’t make it look like a template

The first cut of the layout was just Tao Su's Blog + bold title + Tao Su · Jan 19, 2025 and an accent bar at the bottom. It worked, but it felt like every other generated OG card on the internet — title-only with a wordmark.

So I rebuilt the layout to actually use the metadata I already have:

  • Eyebrow strip at the top — TAO SU · TUTORIAL, uppercase, tracked, muted. Pulls the post’s primary tag from frontmatter.
  • Title at 48pt bold. Big enough to be the dominant element; small enough that 3-line titles still fit.
  • Summary from post.data.summary, truncated to 140 characters with the existing truncateDescription util.
  • Footer: a circular GitHub avatar (fetched once per build), the author name in bold, and ${date} · ${reading time} underneath.
  • Accent bar: 8px orange-500 at the very bottom, matching the orange glow on the avatar in the site header.

The avatar was the most fun part. Sharp can apply an SVG mask via composite with blend: "dest-in", so a circular avatar is just resize → composite-with-circle-mask:

const mask = Buffer.from(
  `<svg width="${AVATAR_SIZE}" height="${AVATAR_SIZE}">
     <circle cx="${AVATAR_SIZE / 2}" cy="${AVATAR_SIZE / 2}" r="${AVATAR_SIZE / 2}" fill="#fff"/>
   </svg>`
)

return await sharp(raw)
  .resize(AVATAR_SIZE, AVATAR_SIZE)
  .composite([{ input: mask, blend: "dest-in" }])
  .png()
  .toBuffer()

I wrapped the fetch in a Promise-cached helper so the avatar only downloads once per build, and falls back to no-avatar if the request fails (so a flaky network during deploy doesn’t break the build):

let avatarPromise: Promise<Buffer | null> | null = null

function getAvatar(): Promise<Buffer | null> {
  if (avatarPromise) return avatarPromise
  avatarPromise = (async () => {
    try {
      const res = await fetch(`https://github.com/tomstao.png?size=${AVATAR_SIZE * 2}`)
      if (!res.ok) return null
      const raw = Buffer.from(await res.arrayBuffer())
      // ...mask + return
    } catch {
      return null
    }
  })()
  return avatarPromise
}

If the avatar isn’t available, the layout collapses gracefully — name and meta slide back to the left edge instead of looking awkwardly indented.

Pango quirks

Two things bit me on Windows (where I do most of my dev) but not on Vercel’s Linux runners:

  1. sans-serif isn’t a Pango generic family. Use Sans — that’s the actual fontconfig generic name. Same idea for Serif and Monospace.
  2. Without a weight, Pango’s font matcher sometimes falls back to a Liberation Serif variant. Always specify Bold, Medium, or Regular in the font description.

The fix was just being explicit everywhere:

font: "Sans Bold 48" // title
font: "Sans Medium 22" // summary
font: "Sans Bold 18" // eyebrow

Optional last-updated dates

Some posts get meaningful edits long after publication. The OG og:article:modified_time and the JSON-LD dateModified field were already being read in BaseHead.astro, but the blog schema didn’t have anywhere to put the value. Easy fix:

// src/content/config.ts
const blog = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    summary: z.string(),
    date: z.coerce.date(),
    updated: z.coerce.date().optional(),
    tags: z.array(z.string()),
    // ...
  }),
})

ArticleTopLayout.astro renders an “Updated” pill next to the published date when updated is present:

{
  updated && (
    <div class="flex items-center gap-2" title={`Last updated ${formatDate(updated)}`}>
      <svg class="size-5 stroke-current">
        <use href="/ui.svg#calendar" />
      </svg>
      Updated {formatDate(updated)}
    </div>
  )
}

And the blog route threads it into the article props so BaseHead can put it in the structured data:

<PageLayout
  title={title}
  description={summary}
  image={`/og/${post.slug}.png`}
  article={{ publishedTime: date, modifiedTime: updated, tags }}
/>

Posts that never get edited stay clean — the field is optional.

Two small fixes worth mentioning

site URL was still the template demo. astro.config.mjs was carrying over https://astro-sphere-demo.vercel.app from the upstream Astro Sphere template. That meant canonical URLs, og:url, the sitemap, and the RSS feed all advertised the wrong domain. Trivial change, real bug.

View-counter API was throwing in local dev. @vercel/kv reads KV_REST_API_URL and KV_REST_API_TOKEN lazily on first method call and throws if they’re missing. Without the KV binding locally, every page load that included <ViewCounter> would fire a 500 with a stack trace into the dev server output. Now the route checks the env vars once and short-circuits to a clean 503 before importing @vercel/kv:

const kvConfigured = Boolean(import.meta.env.KV_REST_API_URL && import.meta.env.KV_REST_API_TOKEN)

const KV_NOT_CONFIGURED = new Response(JSON.stringify({ error: "View counter not configured" }), {
  status: 503,
  headers: { "Content-Type": "application/json" },
})

export const GET: APIRoute = async ({ params }) => {
  if (!params.slug) return badRequest()
  if (!kvConfigured) return KV_NOT_CONFIGURED

  const { kv } = await import("@vercel/kv")
  // ...
}

The <ViewCounter> client already hides itself on any non-2xx response, so the badge just disappears in dev instead of looking broken.

A roadmap, on purpose

Every time I touch the blog I think of three more things I’d like to do, and most of them never make it past my own short-term memory. So I started docs/plans/feature-roadmap.md — twenty proposals grouped by theme (quick wins, engagement, discoverability, portfolio polish, perf/SEO, dev experience), each with a status (Idea / Planned / In progress / Done) and a one-line note on scope.

The doc isn’t a commitment — most rows are still Idea. The point is that the next time I sit down to work on the blog, I’m not starting from a blank page. And when something ships, the implementation log at the bottom captures the date and the files touched, which is more useful than re-reading commit messages later.

Key takeaways

  • Sharp’s text: input source is enough for a polished OG layout — Pango markup gives you per-span color, weight, and letter-spacing. No need for Satori or @vercel/og for typical post cards.
  • Static endpoints with getStaticPaths are perfect for build-time image generation — every post gets a PNG, served from the static bundle, with full immutable caching.
  • Use real metadata in OG images — title, summary, primary tag, reading time, author avatar. Title-only cards look generic; metadata-rich cards look intentional.
  • Specify Pango weights explicitlySans Medium 24, not Sans 24. Saves you a debugging session when a font falls back to serif on Linux.
  • A short roadmap doc beats a mental list — even just twenty lines is enough to stop forgetting good ideas.