Skip to content

Automatic OG images from MDX frontmatter

August 11, 2025 (1y ago)

370 views

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 sitting in MDX frontmatter: title, summary, date. The fix was a tiny endpoint that turns that frontmatter into a good-looking OG image on its own.

Now I push a post, it deploys, and the link previews properly wherever I share it. No Figma round trip.


How it works

  1. Expose post data (slug → title, summary, publishedAt).
  2. Add a small /api/og/[slug] route to render a 1200×630 image on demand.
  3. 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)

// lib/content/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 "@/lib/content/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 }
  );
}

If you want branded typography, embed a font: fetch the .ttf and 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 finished

  • Set the title big and the summary small, so titles can wrap without the summary taking over.
  • Stick to one colour rather than a rainbow. Dark background, light text, legible every time.
  • Clamp long titles. If you write essay-length titles, drop the font-size slightly after about 70 characters.
  • Emoji are fine. If they render badly, switch to Twemoji SVGs.

Try it

Add the route, render a plain card, and point og:image at it. Share a post to Slack or Twitter and you should see a clean preview. If you like the result, add a brand font and a logo dot next.

In an enterprise production environment I'd pre-generate PNGs at build time or set a long cache header. You don't need either of those to get good previews working today.

Written by Sam Ainsworth.

my face
© Sam Ainsworth 2024 - 2026. All Rights Reserved.privacy