Table of Contents
How to add Auto Generated Preview Images for Your Next.js Blog
July 8, 2025 (4 days ago)
-
If you want your blog posts to look great when shared on social media, you need Open Graph (OG) images. Next.js 13+ makes it possible to generate these images dynamically for each post, but there are some important caveats—especially around file system access and static vs. dynamic generation.
This tutorial will walk you through the process, highlight the pitfalls I hit, and show you the final working solution.
Why Dynamic OG Images?
Static OG images are fine, but dynamic ones let you:
- Show the post title, tags, and excerpt on the image
- Automatically update images when you change your content
- Avoid manually designing a new image for every post
Step 1: The Naive Approach (and Why It Fails)
My first attempt was to use a dynamic route handler for OG images:
// app/blog/[slug]/opengraph-image.tsx
import { getBlogPostBySlug } from "@/lib/postsLoaders";
import { ImageResponse } from "next/og";
export const alt = "My Blog";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: { slug: string } }) {
const post = getBlogPostBySlug(params.slug);
// ...generate image...
}
My getBlogPostBySlug
function used fs.readFileSync
to read markdown files from disk. This worked perfectly in development.
But in production, all OG images broke.
Step 2: Understanding the Problem
Here’s what I learned:
- In development:
Your local server has access to the file system, sofs
works. - In production (Vercel, Netlify, etc.):
Dynamic route handlers (likeopengraph-image.tsx
) run in a serverless/edge environment, which does not have access to your project’s file system (except for/public
).
Any attempt to usefs
to read files will fail.
Key lesson:
You cannot use
fs
to read files in dynamic route handlers in production.
Step 3: The Docs Confusion
The Next.js docs show examples using fetch
or even fs
to load assets. The key detail is:
- If you use
generateStaticParams
to enumerate all possible slugs at build time, Next.js will generate the OG images as static assets, and you can usefs
(because it runs at build time). - If you don’t, the handler runs at request time in production, and
fs
will fail.
Step 4: The Solution — Static Generation with generateStaticParams
To make it work, I added a generateStaticParams
export to my OG image route:
// app/blog/[slug]/opengraph-image.tsx
export async function generateStaticParams() {
const { getAllBlogPosts } = await import("@/lib/postsLoaders");
const posts = getAllBlogPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Now, Next.js knows all possible slugs at build time and generates the OG images as static files. This means:
- You can use
fs
in your handler (it runs at build time). - Your OG images are fast and reliable in production.
Step 5: The Final Working Code
Here’s the complete code for my OG image route:
// app/blog/[slug]/opengraph-image.tsx
import { getBlogPostBySlug } from "@/lib/postsLoaders";
import { ImageResponse } from "next/og";
export const alt = "jakmaz.com";
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
export default async function Image({ params }: { params: { slug: string } }) {
const post = getBlogPostBySlug(params.slug);
if (!post) {
return new Response("Not found", { status: 404 });
}
return new ImageResponse(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
backgroundColor: "#FFFFFF",
color: "#111827",
padding: "64px",
fontFamily: "sans-serif",
boxSizing: "border-box",
position: "relative",
}}
>
{/* Left accent bar */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "12px",
height: "100%",
backgroundColor: "#000000",
}}
/>
{/* Title + Tags + Excerpt */}
<div style={{ display: "flex", flexDirection: "column", gap: "32px" }}>
{/* Tags */}
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
{post.tags.map((tag, index) => (
<div
key={index}
style={{
backgroundColor: "#000000",
color: "#FFFFFF",
padding: "8px 16px",
borderRadius: "8px",
fontSize: "18px",
fontWeight: 600,
}}
>
{tag}
</div>
))}
</div>
<h1
style={{
fontSize: 72,
fontWeight: 800,
lineHeight: 1.1,
margin: 0,
}}
>
{post.title}
</h1>
<p
style={{
fontSize: 32,
color: "#6B7280",
margin: 0,
maxWidth: "80%",
lineHeight: 1.4,
}}
>
{post.excerpt}
</p>
</div>
{/* Footer author name */}
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
fontSize: 28,
color: "#6B7280",
fontWeight: 600,
}}
>
<span>jakmaz.com</span>
<span>{new Date(post.date).toLocaleDateString()}</span>
</div>
</div>,
{
...size,
},
);
}
export async function generateStaticParams() {
const { getAllBlogPosts } = await import("@/lib/postsLoaders");
const posts = getAllBlogPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Step 6: Alternative — Dynamic API Route (No fs
!)
If you want to generate OG images dynamically (e.g., always up-to-date, or for slugs not known at build time), you can use an API 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") || "My Blog";
// ...generate image using title...
return new ImageResponse(<div>{title}</div>);
}
But:
You cannot use fs
in this API route in production. Pass all needed data via query params, or use a database or pre-bundled JSON.
Key Takeaways
- Don’t use
fs
in dynamic route handlers unless you’re sure they run at build time. - Use
generateStaticParams
to statically generate OG images for all known slugs. - For dynamic images, use an API route and pass all data via query params or use a runtime-available data source.