Next.js Dynamic Image Generation: OG Images, Social Cards & More

Next.js Dynamic Image Generation: OG Images, Social Cards & More

Every page on your Next.js site could have a unique social preview image. Blog posts with their titles. Product pages with photos and prices. User profiles with avatars and stats.

Most sites don't do this because generating images on the fly sounds complicated. It's not.

With Next.js API routes and an image generation API, you can create dynamic images for any page in your app. The setup takes about an hour. Here's exactly how to do it.

What were buildingWhat We're Building

By the end of this tutorial, you'll have:

  1. Dynamic OG images for blog posts (title becomes the image headline)
  2. Social cards for products (photo, price, and name combined)
  3. Personalized share images for user profiles

All generated on demand, cached for performance, and served through your Next.js app.

The two approachesThe Two Approaches

You can generate images at two different times:

Build time static generationBuild Time (Static Generation)

Generate images when you build your site. Best for content that doesn't change often.

Pros:

  • Fastest load times (images already exist)
  • No runtime API calls

Cons:

  • Requires rebuild for updates
  • Can't personalize for logged-in users

Request time on demandRequest Time (On Demand)

Generate images when someone requests them. Best for dynamic content.

Pros:

  • Always current
  • Can personalize per user

Cons:

  • First request is slower
  • Requires caching strategy

For most sites, request time with caching is the best balance.

Setting up the api routeSetting Up the API Route

Create an API route that generates images from your template.

Basic setup app routerBasic Setup (App Router)

// app/api/og/route.ts
import { NextRequest, NextResponse } from "next/server"
 
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const title = searchParams.get("title") || "Default Title"
 
  // Call Imejis.io - returns image directly
  const response = await fetch(
    `https://render.imejis.io/v1/${process.env.IMEJIS_TEMPLATE_ID}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title }),
    }
  )
 
  // Get image buffer
  const imageBuffer = await response.arrayBuffer()
 
  // Return image directly
  return new NextResponse(imageBuffer, {
    headers: {
      "Content-Type": "image/png",
      "Cache-Control": "public, max-age=86400, s-maxage=86400",
    },
  })
}

For pages routerFor Pages Router

// pages/api/og.ts
import type { NextApiRequest, NextApiResponse } from "next"
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { title = "Default Title" } = req.query
 
  const response = await fetch(
    `https://render.imejis.io/v1/${process.env.IMEJIS_TEMPLATE_ID}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: String(title) }),
    }
  )
 
  const imageBuffer = await response.arrayBuffer()
 
  res.setHeader("Content-Type", "image/png")
  res.setHeader("Cache-Control", "public, max-age=86400, s-maxage=86400")
  res.send(Buffer.from(imageBuffer))
}

Adding og meta tagsAdding OG Meta Tags

Now connect your pages to the API route.

App router with generatemetadataApp Router with generateMetadata

// app/blog/[slug]/page.tsx
import { Metadata } from "next"
 
import { getPost } from "@/lib/posts"
 
interface Props {
  params: { slug: string }
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost(params.slug)
 
  const ogImageUrl = new URL("/api/og", process.env.NEXT_PUBLIC_SITE_URL)
  ogImageUrl.searchParams.set("title", post.title)
 
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [
        {
          url: ogImageUrl.toString(),
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [ogImageUrl.toString()],
    },
  }
}
 
export default async function BlogPost({ params }: Props) {
  const post = await getPost(params.slug)
  // ... render post
}

Pages router with nextheadPages Router with next/head

// pages/blog/[slug].tsx
import Head from "next/head"
 
export default function BlogPost({ post }) {
  const ogImageUrl = `${
    process.env.NEXT_PUBLIC_SITE_URL
  }/api/og?title=${encodeURIComponent(post.title)}`
 
  return (
    <>
      <Head>
        <meta property="og:image" content={ogImageUrl} />
        <meta property="og:image:width" content="1200" />
        <meta property="og:image:height" content="630" />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:image" content={ogImageUrl} />
      </Head>
      {/* ... page content */}
    </>
  )
}

Advanced multiple image typesAdvanced: Multiple Image Types

Your site probably needs different images for different purposes.

Route with template selectionRoute with Template Selection

// app/api/og/[type]/route.ts
import { NextRequest, NextResponse } from "next/server"
 
const templates = {
  blog: process.env.BLOG_TEMPLATE_ID,
  product: process.env.PRODUCT_TEMPLATE_ID,
  profile: process.env.PROFILE_TEMPLATE_ID,
}
 
export async function GET(
  request: NextRequest,
  { params }: { params: { type: string } }
) {
  const searchParams = request.nextUrl.searchParams
  const templateId = templates[params.type as keyof typeof templates]
 
  if (!templateId) {
    return new NextResponse("Invalid type", { status: 400 })
  }
 
  // Build payload based on type
  const payload = buildPayload(params.type, searchParams)
 
  const response = await fetch(`https://render.imejis.io/v1/${templateId}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  })
 
  const imageBuffer = await response.arrayBuffer()
 
  return new NextResponse(imageBuffer, {
    headers: {
      "Content-Type": "image/png",
      "Cache-Control": "public, max-age=86400",
    },
  })
}
 
function buildPayload(type: string, params: URLSearchParams) {
  switch (type) {
    case "blog":
      return {
        title: params.get("title"),
        author: params.get("author"),
        date: params.get("date"),
      }
    case "product":
      return {
        name: params.get("name"),
        price: params.get("price"),
        image: params.get("image"),
      }
    case "profile":
      return {
        username: params.get("username"),
        avatar: params.get("avatar"),
        bio: params.get("bio"),
      }
    default:
      return { title: params.get("title") }
  }
}

Now you can use:

  • /api/og/blog?title=...&author=...
  • /api/og/product?name=...&price=...
  • /api/og/profile?username=...

Caching strategiesCaching Strategies

Image generation takes time. Caching is essential.

Http cache headersHTTP Cache Headers

The simplest approach. Already included in examples above:

'Cache-Control': 'public, max-age=86400, s-maxage=86400'

This caches images for 24 hours at the edge (Vercel, Cloudflare, etc.).

In memory cacheIn-Memory Cache

For more control:

// lib/imageCache.ts
const cache = new Map<string, { buffer: ArrayBuffer; timestamp: number }>()
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
 
export function getCachedImage(key: string): ArrayBuffer | null {
  const cached = cache.get(key)
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.buffer
  }
  return null
}
 
export function setCachedImage(key: string, buffer: ArrayBuffer): void {
  cache.set(key, { buffer, timestamp: Date.now() })
}

Use in your route:

import { getCachedImage, setCachedImage } from "@/lib/imageCache"
 
export async function GET(request: NextRequest) {
  const cacheKey = request.nextUrl.search
 
  // Check cache first
  const cached = getCachedImage(cacheKey)
  if (cached) {
    return new NextResponse(cached, {
      headers: { "Content-Type": "image/png" },
    })
  }
 
  // Generate new image
  const response = await fetch(/* ... */)
  const imageBuffer = await response.arrayBuffer()
 
  // Cache it
  setCachedImage(cacheKey, imageBuffer)
 
  return new NextResponse(imageBuffer, {
    headers: { "Content-Type": "image/png" },
  })
}

Redis cache productionRedis Cache (Production)

For serverless environments where memory cache doesn't persist:

import { Redis } from "@upstash/redis"
 
const redis = Redis.fromEnv()
 
export async function GET(request: NextRequest) {
  const cacheKey = `og:${request.nextUrl.search}`
 
  // Check Redis
  const cached = await redis.get<string>(cacheKey)
  if (cached) {
    const buffer = Buffer.from(cached, "base64")
    return new NextResponse(buffer, {
      headers: { "Content-Type": "image/png" },
    })
  }
 
  // Generate and cache
  const response = await fetch(/* ... */)
  const imageBuffer = await response.arrayBuffer()
 
  // Store as base64 in Redis
  await redis.setex(
    cacheKey,
    86400, // 24 hours
    Buffer.from(imageBuffer).toString("base64")
  )
 
  return new NextResponse(imageBuffer, {
    headers: { "Content-Type": "image/png" },
  })
}

Build time generationBuild-Time Generation

For static sites, generate images during build.

Static generation with getstaticpropsStatic Generation with getStaticProps

// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
  const post = await getPost(params.slug)
 
  // Generate image at build time
  const response = await fetch(
    `https://render.imejis.io/v1/${process.env.IMEJIS_TEMPLATE_ID}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: post.title }),
    }
  )
 
  const imageBuffer = await response.arrayBuffer()
 
  // Save to public folder
  const imagePath = `/og/${params.slug}.png`
  await fs.writeFile(`./public${imagePath}`, Buffer.from(imageBuffer))
 
  return {
    props: {
      post,
      ogImage: imagePath,
    },
  }
}

With app routerWith App Router

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og"
 
// This generates at build time for static pages
export const runtime = "edge"
export const contentType = "image/png"
export const size = { width: 1200, height: 630 }
 
export default async function OGImage({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)
 
  // Call external API
  const response = await fetch(
    `https://render.imejis.io/v1/${process.env.IMEJIS_TEMPLATE_ID}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: post.title }),
    }
  )
 
  const imageBuffer = await response.arrayBuffer()
 
  return new Response(imageBuffer, {
    headers: { "Content-Type": "image/png" },
  })
}

Testing og imagesTesting OG Images

Before deploying, verify your images work.

Local testingLocal Testing

  1. Start your dev server: npm run dev
  2. Visit: http://localhost:3000/api/og?title=Test%20Title
  3. You should see the generated image

Production testingProduction Testing

Use the platform debugging tools:

These tools fetch your OG image and show exactly what will appear when shared.

Error handlingError Handling

Don't let image generation errors break your pages.

export async function GET(request: NextRequest) {
  const title = request.nextUrl.searchParams.get("title") || "Default"
 
  try {
    const response = await fetch(/* ... */)
 
    if (!response.ok) {
      throw new Error(`API returned ${response.status}`)
    }
 
    const imageBuffer = await response.arrayBuffer()
 
    return new NextResponse(imageBuffer, {
      headers: { "Content-Type": "image/png" },
    })
  } catch (error) {
    console.error("OG image generation failed:", error)
 
    // Return fallback image
    const fallback = await fs.readFile("./public/default-og.png")
    return new NextResponse(fallback, {
      headers: { "Content-Type": "image/png" },
    })
  }
}

Always have a fallback. Never let a failed API call result in a broken preview.

Cost analysisCost Analysis

ComponentMonthly Cost
Vercel Pro$20
Imejis.io Basic$14.99
Total~$35

For a site with 1,000 unique pages, that's about 3.5 cents per OG image. Most images get cached, so actual API calls are much lower.

Check Imejis.io pricing for higher volume needs.

Getting startedGetting Started

Your path:

  1. Create a template in Imejis.io for your OG images
  2. Set up the API route (copy from examples above)
  3. Add meta tags to your pages
  4. Test locally with direct URL access
  5. Deploy and test with social platform tools

Start with blog posts or your most-shared content. Once that's working, expand to products, profiles, or other dynamic pages.

Every page you add becomes instantly shareable with a professional, branded preview. No more generic social cards.

Get started with Imejis.io

FaqFAQ

Does this work on vercels edge runtimeDoes this work on Vercel's Edge Runtime?

Yes. The API route examples work on both Node.js and Edge runtimes. For Edge, use the fetch API as shown, which works in both environments.

How do i handle very long titlesHow do I handle very long titles?

Either truncate in your API route before sending to the template, or design your template to handle long text with auto-sizing. Most image APIs support text that shrinks to fit.

Can i generate images for authenticated usersCan I generate images for authenticated users?

Yes. Access the user's session in your API route and personalize the image. Be careful with caching though. User-specific images shouldn't be cached publicly.

What about rate limitingWhat about rate limiting?

Imejis.io has generous rate limits on paid plans. The bigger concern is your own hosting costs. Caching is your friend. A well-cached setup generates far fewer images than you might expect.

Can i use this with isr incremental static regenerationCan I use this with ISR (Incremental Static Regeneration)?

Yes. Generate the image in getStaticProps or generateStaticParams, and it will regenerate when the page regenerates. This gives you the best of both worlds: static performance with eventual freshness.