Link previews are the first thing anyone sees of a blog post. They're also the last thing most people think about. The usual answers are a Figma file you forget to update, or @vercel/og, or an image-CDN pipeline you stand up once and never touch again.
I do something dumber and more fun: my OG images are just screenshots of my own React routes.
Every page that needs an OG has a sibling route that renders the actual preview. The screenshot is taken at build time with Puppeteer. The result looks like my site because it is my site: same fonts, same dither shader, same Grid, same theme-aware icons.
The trick: render the OG, don't design it
I have three OG routes, each a normal TanStack Router file route returning a normal React component:
/og— the site-wide preview, used as defaultog:image./blog/og— the blog listing preview ("Nathan's Blog")./blog/$slug/og— the per-post preview, with the post title, its date, and a small dither shader header.
You can even see it by yourself: e.g brodin.dev/og.
The component in each route is the same shape: a constrained div with id="og", sized exactly 1200 × 630, reusing my real UI primitives.
<div
id="og"
className="relative mt-2 flex h-full flex-col items-center justify-between overflow-hidden border p-14"
style={{ width: 1200, height: 630 }}
>
{/* ...PageTitle, PageDescription, tech-stack icons... */}
<div className="pointer-events-none absolute inset-0 -z-1 h-full w-full overflow-hidden opacity-10">
<Dithering
width={1200}
height={630}
colorBack={resolvedTheme === 'dark' ? '#000000' : '#FFFFFF'}
colorFront={resolvedTheme === 'dark' ? '#cbfbf1' : '#00786f'}
shape="warp"
type="4x4"
size={1.0}
speed={0}
scale={1.84}
/>
</div>
<Grid />
</div>
That Paper Shaders dither is the same one I use on my section dividers. The PageTitleis the same component that renders the H1 on every real page. The Grid is the sameGrid from my home page. There is no separate design system for OG. The OG is the design system, just constrained to a poster.
Because the per-post route is a real route with a real loader, it pulls the post title and date from Content Collections just like the actual blog post page does. No drift. Update the post, the OG regenerates on the next build.
The screenshot script
scripts/generate-og.ts does the work:
async function captureOg(page: Page, url: string, outputPath: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('#og', { timeout: 30_000 })
const og = await page.$('#og')
if (!og) {
throw new Error(`OG element (#og) not found at ${url}`)
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
await og.screenshot({ path: outputPath, type: 'png' })
const stats = fs.statSync(outputPath)
const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2)
if (stats.size > MAX_FILE_SIZE) {
throw new Error(`OG file size (${fileSizeMB}MB) exceeds 8MB limit: ${outputPath}`)
}
console.log(` ${outputPath} (${fileSizeMB}MB)`)
}
Two details matter here. First, waitUntil: 'domcontentloaded' instead of 'networkidle'. networkidle waits for quiet, which fonts and async chunks can delay by seconds. I don't need quiet. I need the #og element to be painted, so I waitForSelector('#og', { timeout: 30000 }). It's faster and more reliable than waiting for the network to settle.
Second, I screenshot the #og element, not the viewport. The Puppeteer page is set to 1920 × 1080, but the captured image is the element at exactly 1200 × 630. That means the OG box can live anywhere in the layout and I still get a clean crop. No pixel-counting in the script.
One image per blog post, automatically
The script crawls content/blog/*.md and screenshots each post's OG route, writing public/og/blog/<slug>.png:
function getBlogSlugs(): string[] {
return fs
.readdirSync(CONTENT_BLOG_DIR, { recursive: true })
.filter((file) => String(file).endsWith('.md'))
.map((file) => String(file).replace(/\.md$/, ''))
}
New post in content/blog/, run pnpm generate:og, get one new image. No manual step, no Figma export.
The blog post route then wires that image into its head():
const ogImage = `${siteConfig.url}/og/blog/${post.slug}.png`
// ...
{ property: 'og:image', content: ogImage },
{ name: 'twitter:image', content: ogImage },
So updating a post title or date updates the OG on the next build. The link preview always matches the post.
The 8 MB guardrail
After every capture, the script stats the output file and throws if it exceeds 8 MB. A single bloated OG would silently tank sharing-time LCP the moment a link gets pasted somewhere busy. I'd rather have the build fail. It's six lines of code and the cheapest insurance I ship.
Theme-aware icons, for free
The site-wide OG includes my tech-stack icons. Each one has light and dark variants, swapped with Tailwind's dark: variants:
<img
src={`/tech-stack-icons/${tech.key}-light.svg`}
alt={`${tech.title} light icon`}
className="block dark:hidden"
/>
<img
src={`/tech-stack-icons/${tech.key}-dark.svg`}
alt={`${tech.title} dark icon`}
className="hidden dark:block"
/>
The dither shader does the same thing at the color level: colorBack and colorFront switch on resolvedTheme. So a dark-mode screenshot and a light-mode screenshot look like genuinely different posters, not the same image with inverted colors. I run the script under whichever theme I want; usually dark, since that's my brand default.
The honest downsides
- Build-time, not on-demand. New content needs a redeploy and a script run. If I wanted to ship a post and have its OG live in under a minute, I would reach for
@vercel/og. - Puppeteer is heavy. It pulls Chromium into your CI. It adds seconds-per-image to the build. For a portfolio with a handful of routes it's a non-issue, but if I had thousands of pages I'd reconsider.
- Single captures. One theme per run. If I wanted dark and light OGs I'd have to render the route twice. Right now I don't.
@vercel/og is the right answer once you have a real scale problem. The reason I don't use it here isn't that it's bad: it's that I don't have that problem, and og.screenshot() lets my OG reuse the components I've already built.
The OG route components, side by side, are in /src/routes/og/, /src/routes/blog/og/, and /src/routes/blog/$slug/og/. Worth reading in order: the per-post one is where most of the logic lives.
- My Portfolio: brodin.dev
- Source code: github.com/NathanBrodin/Portfolio
@vercel/og(when you do have the scale problem): vercel.com/docs/functions/og-image-generation- Paper Shaders (for the dither ones): shaders.paper.design