MDX Open Graph Images: Auto-Generate OG Images from Frontmatter
How to auto-generate og:image for MDX blog posts using Next.js, Contentlayer, and Velite — with frontmatter-driven metadata and dynamic edge images.
The MDX + OG image workflow
MDX-based blogs are common in the Next.js ecosystem — used in documentation sites, personal blogs, and company blogs. The challenge is that each MDX file is essentially a standalone piece of content without an automatic social preview image.
The solution is to use frontmatter fields from your MDX files to drive OG metadata generation, and then either reference a static image or generate a dynamic one using Next.js's edge image generation.
MDX frontmatter for OG tags
Define the fields you need in your MDX frontmatter:
--- title: "How We Cut Our Load Time by 60%" description: "We rewrote our data fetching layer and cut page load time by 60%. Here's what we learned." date: "2026-03-01" author: "Jane Smith" image: "/og-images/load-time-post.png" # optional: static OG image tags: ["performance", "react", "nextjs"] --- # How We Cut Our Load Time by 60% Content starts here...
The image field is optional — if present, use it as the OG image. If absent, auto-generate one from the title.
Option 1: Velite (modern, type-safe)
Velite is the modern replacement for Contentlayer with active maintenance. Define your schema:
// velite.config.ts
import { defineConfig, defineCollection, s } from "velite";
const posts = defineCollection({
name: "Post",
pattern: "content/posts/**/*.mdx",
schema: s
.object({
title: s.string(),
description: s.string(),
date: s.isodate(),
author: s.string().optional(),
image: s.string().optional(),
slug: s.slug("posts"),
})
.transform((data) => ({
...data,
// Auto-generate OG image URL if not provided
ogImage: data.image || `/api/og?title=${encodeURIComponent(data.title)}`,
})),
});
export default defineConfig({ collections: { posts } });// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { posts } from ".velite";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = posts.find((p) => p.slug === params.slug);
if (!post) return { title: "Not Found" };
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
publishedTime: post.date,
images: [{ url: post.ogImage, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.description,
images: [post.ogImage],
},
};
}Option 2: next-mdx-remote with gray-matter
// lib/mdx.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const postsDir = path.join(process.cwd(), "content/posts");
export function getPost(slug: string) {
const fullPath = path.join(postsDir, `${slug}.mdx`);
const raw = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(raw);
return {
slug,
title: data.title as string,
description: data.description as string,
date: data.date as string,
image: data.image as string | undefined,
content,
};
}
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/mdx";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = getPost(params.slug);
const ogImage =
post.image ||
`/api/og?title=${encodeURIComponent(post.title)}`;
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
images: [{ url: ogImage, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
images: [ogImage],
},
};
}Dynamic OG image edge route
For auto-generated OG images from titles, add an edge route:
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") || "Blog Post";
const author = searchParams.get("author") || "";
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
background: "linear-gradient(135deg, #0a0a0a 0%, #1a0a2e 100%)",
width: "100%",
height: "100%",
padding: "80px",
justifyContent: "space-between",
}}
>
{/* Logo/site name */}
<div style={{ fontSize: 24, color: "#8b5cf6", fontWeight: 700 }}>
myblog.com
</div>
{/* Post title */}
<div
style={{
fontSize: title.length > 60 ? 44 : 56,
fontWeight: 900,
color: "#ffffff",
lineHeight: 1.1,
}}
>
{title}
</div>
{/* Author */}
{author && (
<div style={{ fontSize: 22, color: "#71717a" }}>
by {author}
</div>
)}
</div>
),
{ width: 1200, height: 630 }
);
}Using the opengraph-image.tsx convention
Next.js 13+ supports a co-located opengraph-image.tsx file that auto-wires as the OG image for that route:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/mdx";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OgImage({
params,
}: {
params: { slug: string };
}) {
const post = getPost(params.slug);
return new ImageResponse(
(
<div style={{ /* your design */ }}>
{post.title}
</div>
),
size
);
}Next.js automatically adds the og:image meta tag pointing to /blog/[slug]/opengraph-image — no manual metadata configuration needed for the image.
Preview your MDX blog OG images
After deploying, paste any blog post URL into OGFixer to verify your auto-generated OG images look correct on Twitter, LinkedIn, Discord, and Slack.
Check your OG tags free →