# Generate Next.js OG Images with VectorFast Render

> Add on-brand Open Graph images to a Next.js App Router site with VectorFast Render, using generateMetadata or opengraph-image, signed URLs or the REST API, and a fair comparison with next/og.

Canonical URL: https://vectorfast.ai/render/guides/nextjs-og-image
Markdown URL: https://vectorfast.ai/render/guides/nextjs-og-image.md
Last modified: 2026-10-30

**Short answer:** in a Next.js App Router page, return a VectorFast Render image URL from `generateMetadata` as `openGraph.images`. Get the URL either by calling `POST /v1/renders` on the server (works in early access) or by signing a `/i/blog-post.png` URL on the server (once per-key signing secrets are issued). Either way the image is rendered once and then served from cache.

This guide uses the `blog-post` template (1200×630). Any [template](/render/templates) works the same way. It was written against the Next.js docs for `generateMetadata` and `opengraph-image` (Next.js 16, pages last updated 2026-08-19 and 2026-07-09, read on 2026-10-30).

## Before you start [#before-you-start]

* A VectorFast Render API key in `VECTORFAST_API_KEY` (see the [quickstart](/render/docs/quickstart)). It is a server secret: read it only in Server Components, route handlers, and `generateMetadata`, never in a `'use client'` file.
* Optional: a brand kit id in `VECTORFAST_BRAND_KIT`. Without it, renders use your default brand kit.

## Option 1: call the REST API in generateMetadata [#option-1-call-the-rest-api-in-generatemetadata]

Create a small server-only helper:

```ts title="lib/og-image.ts"
import 'server-only' // npm install server-only: fails the build if a Client Component imports this file

interface OgCard {
  title: string
  excerpt?: string
  category?: string
  author?: string
}

export async function ogImageUrl(card: OgCard): Promise<string | null> {
  const response = await fetch('https://vectorfast.ai/v1/renders', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.VECTORFAST_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      template: 'blog-post@1',
      width: 1200,
      height: 630,
      brand_kit: process.env.VECTORFAST_BRAND_KIT || undefined,
      params: card,
    }),
    // The same params always return the same URL, so the response can be cached.
    cache: 'force-cache',
  })
  if (!response.ok)
    return null
  const render = await response.json() as { url: string }
  return render.url
}
```

Use it in the page:

```tsx title="app/blog/[slug]/page.tsx"
import type { Metadata } from 'next'
import { getPost } from '@/lib/posts'
import { ogImageUrl } from '@/lib/og-image'

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  const image = await ogImageUrl({
    title: post.title,
    excerpt: post.summary,
    category: post.category,
    author: post.author,
  })

  return {
    title: post.title,
    openGraph: {
      images: image ? [{ url: image, width: 1200, height: 630, alt: post.title }] : [],
    },
  }
}
```

What happens:

* The first time a post's metadata is generated, VectorFast renders the card and returns a signed `url`.
* The returned URL is immutable: crawlers and chat apps that fetch it get a one-year `Cache-Control: immutable` response.
* The same title, excerpt, and brand kit version always produce the same cache key, so regenerating the page (a rebuild, a revalidation) returns `cached: true` and does not render again. Once billing is connected, those cache hits cost nothing.
* If the API is unreachable, the helper returns `null` and the page still renders without an image; handle that however your site prefers, for example with a static fallback image.

Text longer than a parameter's limit is rejected (`title` is at most 110 characters for `blog-post`), so trim long titles before sending them.

## Option 2: sign the image URL on your server [#option-2-sign-the-image-url-on-your-server]

A signed URL is computed locally with an HMAC, so it costs no network request at build or render time. The image is rendered the first time someone fetches the URL.

<Callout title="Not available yet">
  Per-key signing secrets are not issued in early access. Until they are, use option 1; its `url` is the same kind of signed URL.
</Callout>

Copy `signRenderUrl` from [Signed URLs](/render/docs/signed-urls#nodejs) into `lib/sign-render-url.ts`, then:

```tsx title="app/blog/[slug]/page.tsx"
import type { Metadata } from 'next'
import { getPost } from '@/lib/posts'
import { signRenderUrl } from '@/lib/sign-render-url'

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  const url = signRenderUrl({
    template: 'blog-post',
    width: 1200,
    height: 630,
    brand: process.env.VECTORFAST_BRAND_KIT_VERSION, // for example "bk_…@3"
    params: { title: post.title, excerpt: post.summary, category: post.category },
  })

  return {
    title: post.title,
    openGraph: { images: [{ url, width: 1200, height: 630, alt: post.title }] },
  }
}
```

Signed URLs for an account brand kit must pin a version (`bk_…@3`), so a stored URL never changes pixels. Bump the version in your environment when you change the kit.

## Option 3: keep an opengraph-image route [#option-3-keep-an-opengraph-image-route]

If your site already uses the `opengraph-image.tsx` file convention, you can keep the route and have it return the VectorFast image. The default export must return a `Response`:

```tsx title="app/blog/[slug]/opengraph-image.tsx"
import { getPost } from '@/lib/posts'
import { ogImageUrl } from '@/lib/og-image'

export const alt = 'Blog post'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  const url = await ogImageUrl({ title: post.title, excerpt: post.summary })
  if (!url)
    return new Response('Image unavailable', { status: 502 })
  const image = await fetch(url)
  return new Response(image.body, { headers: { 'Content-Type': 'image/png' } })
}
```

Next.js caches generated `opengraph-image` routes by default unless they use request-time APIs, so this runs once per post per build or revalidation. The trade-off: your deployment now proxies the image bytes. Options 1 and 2 point crawlers straight at the VectorFast URL instead.

## VectorFast Render or next/og? [#vectorfast-render-or-nextog]

Next.js ships `ImageResponse` from `next/og` (the `@vercel/og` library), which renders JSX to PNG inside your own functions. It is free, open source, and a good fit when one Next.js app needs OG images and you want to own the layout code.

VectorFast Render fits better when:

* the same on-brand cards are needed outside this one app (other sites, CI, an AI agent);
* you want one versioned brand kit, including a logo traced from a PNG, instead of fonts and colors copied into each project;
* you want renders cached and deduplicated across every caller.

`next/og` has limits of its own: only flexbox and a subset of CSS, `ttf`/`otf`/`woff` fonts that you load yourself, and a 500 KB bundle limit for the code, fonts, and images in the function (Vercel docs, read 2026-10-30). See the full [VectorFast Render vs Vercel OG comparison](/render/alternatives/vercel-og).

## FAQ [#faq]

**Will social apps show the image immediately?** The first fetch of a new URL renders the image (tens of milliseconds in local tests, plus font loading; edge timings are not published yet); later fetches are cache hits.

**Can I use a 1080×1080 image for LinkedIn or Instagram?** Yes. Pick a template that supports that size, such as `launch-post` or `social-square`, and pass `width` and `height`.

**What if a title has Chinese or Japanese characters?** Templates load a CJK font subset automatically when the text needs it.
