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 existingtruncateDescriptionutil. - 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:
sans-serifisn’t a Pango generic family. UseSans— that’s the actual fontconfig generic name. Same idea forSerifandMonospace.- Without a weight, Pango’s font matcher sometimes falls back to a Liberation Serif variant. Always specify
Bold,Medium, orRegularin 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/ogfor typical post cards. - Static endpoints with
getStaticPathsare 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 explicitly —
Sans Medium 24, notSans 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.