I was either shipping posts with bland link previews or hand-designing images I didn't have time for. The data I needed was already there — title, summary, date — sitting in MDX frontmatter. The fix: a tiny endpoint that turns that frontmatter into a good-looking OG image automatically.
Result: push a post → deploy → share the link → solid preview every time. No Figma. No copy/paste.
How it works (tiny)
- Expose post data (slug → title, summary, publishedAt).
- Add a small
/api/og/[slug]
route to render a 1200×630 image on demand. - Point
og:image
to that route in your post page<head>
.
That's it. Here's the minimal code.
1) Post index (whatever you already have)
// app/db/blog.ts
export type Metadata = {
title: string;
publishedAt: string;
summary: string;
image?: string;
};
export function getBlogPosts() {
return getMDXData(path.join(process.cwd(), 'content'));
}
2) The image route
// app/api/og/[slug]/route.ts
import { ImageResponse } from "next/server";
import { getBlogPosts } from "@/app/db/blog";
// Edge runtime = fast cold starts for tiny images
export const runtime = "edge";
export async function GET(_: Request, { params }: { params: { slug: string } }) {
const posts = getBlogPosts();
const post = posts.find(p => p.slug === params.slug);
if (!post) return new Response("Not found", { status: 404 });
const { metadata } = post;
return new ImageResponse(
(
<div
style={{
width: 1200,
height: 630,
display: "flex",
flexDirection: "column",
background: "#0b1220",
color: "#e5e7eb",
padding: 64,
gap: 28
}}
>
<div style={{ fontSize: 56, lineHeight: 1.05, letterSpacing: -0.5 }}>
{metadata.title}
</div>
<div style={{ fontSize: 28, opacity: 0.9, maxWidth: 960 }}>
{metadata.summary}
</div>
<div style={{ marginTop: "auto", fontSize: 24, opacity: 0.8 }}>
{new Date(metadata.publishedAt).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric"
})}
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
Tip: If you want branded typography, embed a font: fetch the .ttf
, pass it in fonts: [{ name, data, weight }]
.
3) Wire it into <head>
// app/blog/[slug]/page.tsx (using Next.js 13+ Metadata API)
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata | undefined> {
const { slug } = await params;
let post = getBlogPosts().find((post) => post.slug === slug);
if (!post) {
return;
}
let {
title,
publishedAt: publishedTime,
summary: description,
image,
} = post.metadata;
let ogImage = image
? `https://ainsworth.dev${image}`
: `https://ainsworth.dev/api/og/${post.slug}`;
return {
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime,
url: `https://ainsworth.dev/blog/${post.slug}`,
images: [{ url: ogImage }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
Design tips that make it look "done"
- Big title, small summary. Titles wrap; summaries don't dominate.
- One color, not a rainbow. A dark background with light text is consistent and legible.
- Clamp long titles. If you write essay-length titles, reduce font-size slightly after ~70 chars.
- Emoji are fine. If they look off, switch to Twemoji SVGs.
Your turn (5 minutes)
- Add the route, render a plain card, and point
og:image
at it. - Share a post to Slack/Twitter — you should see a clean preview.
- If you like it, add a brand font and logo dot next.
In an enterprise production environment I would pre-generate PNGs at build or set a long cache header — but you don't need either to get great previews today.