Jakub Mazur

Web Developer

Maastricht Netherlands

Back

How to add Auto Generated Preview Images for Your Next.js Blog

July 8, 2025 (4 days ago)

-

100 views

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, so fs works.
  • In production (Vercel, Netlify, etc.):
    Dynamic route handlers (like opengraph-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 use fs 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 use fs (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.
© 2025 - Jakub Mazur