---
title: "Next.js Image Generation: OG & Social Cards"
description: "Add dynamic image generation to Next.js apps. Create OG images, social cards, and personalized graphics with API routes. TypeScript examples."
url: "https://www.imejis.io/blogs/tutorials/nextjs-dynamic-image-generation"
published: "2026-02-25"
---

# Next.js Image Generation: OG & Social Cards

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. (Not sure whether to use Puppeteer, Satori, or a template API? Read our [comparison guide](/blogs/tutorials/puppeteer-vs-satori-vs-template-api) first.)

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 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. If you specifically need OG images, our dedicated [dynamic OG image generation](/blogs/tutorials/generate-og-images-dynamically) guide goes deeper on that topic.

## The Two Approaches

You can generate images at two different times:

### Build 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 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. If you're building a React app that isn't Next.js, see our [React image generation component](/blogs/tutorials/react-image-generation-component) guide instead.

## Setting Up the API Route

Create an API route that generates images from your template.

### Basic Setup (App Router)

```typescript
// 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 Router

```typescript
// 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 Tags

Now connect your pages to the API route.

### App Router with generateMetadata

```typescript
// 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 next/head

```tsx
// 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 Types

Your site probably needs different images for different purposes.

### Route with Template Selection

```typescript
// 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 Strategies

Image generation takes time. Caching is essential.

### HTTP Cache Headers

The simplest approach. Already included in examples above:

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

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

### In-Memory Cache

For more control:

```typescript
// 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:

```typescript
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 (Production)

For serverless environments where memory cache doesn't persist:

```typescript
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 Generation

For static sites, generate images during build.

### Static Generation with getStaticProps

```typescript
// 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 Router

```typescript
// 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 Images

Before deploying, verify your images work.

### Local 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 Testing

Use the platform debugging tools:

- **Facebook:** [Sharing Debugger](https://developers.facebook.com/tools/debug/)
- **Twitter/X:** Post a link in a draft tweet to preview the card (the standalone Card Validator was discontinued)
- **LinkedIn:** [Post Inspector](https://www.linkedin.com/post-inspector/)

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

## Error Handling

Don't let image generation errors break your pages.

```typescript
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 Analysis

| Component       | Monthly Cost |
| --------------- | ------------ |
| Vercel Pro      | $20          |
| Imejis.io Basic | $14.99       |
| **Total**       | ~$35         |

_Pricing reflects published rates as of July 2026; check each provider's site for current plans._

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](/pricing) for higher volume needs.

## Getting Started

Your path:

1. **Create a template** in [Imejis.io](https://www.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. For general Node.js integration patterns, see our [Node.js integration guide](/blogs/node-js-integration), and if you're new to image APIs entirely, start with [how to generate images with an API](/blogs/tutorials/how-to-generate-images-with-api).

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

[Get started with Imejis.io](https://www.imejis.io)

## FAQ

### Does 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 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 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 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 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.
