Guide · Next.js App Router

Next.js OG images: the built-in route, and when an API fits

Next.js can generate Open Graph images itself. This page shows that route first, then the cases where an image API such as Shotvik fits better, with code for both.

Checked against the Next.js docs and the Shotvik API on 4 October 2026.

The built-in route: opengraph-image with ImageResponse

In the App Router, put an opengraph-image.tsx file in a route segment and default-export a function that returns an image. Next.js adds the og:image tag, with its type, width and height, for that segment. The usual way to draw the image is ImageResponse from next/og:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/posts'; // your own data source

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);
  return new ImageResponse(
    (
      <div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center',
                    justifyContent: 'center', background: 'white', fontSize: 64 }}>
        {post.title}
      </div>
    ),
    { ...size },
  );
}

From the Next.js docs, checked 2026-10-04:

  • Generated images are statically optimized (generated at build time and cached) unless they use request-time APIs or uncached data.
  • params is a promise since Next.js 16.
  • A plain opengraph-image.png, .jpg or .gif file in a segment works too, up to 8 MB.

Under the hood, Vercel’s docs say that @vercel/og, which next/og provides, uses Satori and Resvg to turn HTML and CSS into a PNG, and that it’s included in App Router projects (Vercel docs, checked 2026-10-04). The Satori README lists the CSS properties Satori supports (checked 2026-10-04).

If your card is text, a logo and a background in a flexbox layout, this route is a good default: there’s no extra service, no API key and no quota.

When an external image API fits

  • Your stack isn’t only Next.js. A signed image URL works the same from Next.js, a Rails or Django app, a WordPress theme or a static site, so one card design can serve all of them.
  • You want an existing page as an image. ImageResponse draws the JSX you give it. To capture a page as a browser shows it, for a link thumbnail or a visual check, you need a browser, for example POST /v1/screenshot.
  • The data is on a product page. /v1/og/from-url reads the name, price and image from a product page’s JSON-LD or Open Graph tags and returns a card. You don’t write a template.
  • Your design needs CSS outside the subset Satori documents, such as display: grid, <style> blocks or WOFF2 fonts. Shotvik renders templates in headless Chromium. The side-by-side overview quotes what each project documents, with sources.
  • You’d rather keep image rendering out of your own deployment, including the fonts and assets it needs.

It works the other way too: if ImageResponse already renders your design, an API adds a network call, a monthly quota and a dependency on another service.

Option 1: a signed Shotvik URL in generateMetadata

For blog posts and other pages where you pass the text yourself, sign a GET /v1/og URL on the server and put it in your metadata. Crawlers fetch the PNG from Shotvik; your API key and signing secret never reach the browser. The full Next.js guide has the server-only signing helper, a stored template and a test checklist. The core is short:

// app/blog/[slug]/page.tsx (excerpt)
import type { Metadata } from 'next';
import { shotvikOgUrl } from '@/lib/shotvik-og'; // the helper from the guide
import { getPost } from '@/lib/posts';

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  const image = shotvikOgUrl({
    template: 'article',
    vars: { category: post.category, title: post.title, author: post.author, date: post.date, site: 'example.com' },
    cacheTtl: 604800, // 7 days
  });
  return {
    title: post.title,
    openGraph: { images: [{ url: image, width: 1200, height: 630, alt: post.title }] },
    twitter: { card: 'summary_large_image', images: [image] },
  };
}

If the route also has an opengraph-image file, remove it: Next.js gives file-based metadata priority over generateMetadata.

Option 2: product pages with /v1/og/from-url

For a shop built with Next.js, sign a link to the product URL card instead. Shotvik fetches the public product page, reads its JSON-LD or Open Graph data and renders the built-in product card. You can see real output on the live demo.

// lib/shotvik-product-og.ts
import 'server-only';
import { createHmac } from 'node:crypto';

export function shotvikProductOgUrl(productUrl: string, cacheTtl = 86400): string {
  const kid = process.env.SHOTVIK_KEY_ID;
  const secret = process.env.SHOTVIK_SIGNING_SECRET;
  if (!kid || !secret) throw new Error('SHOTVIK_KEY_ID and SHOTVIK_SIGNING_SECRET must be set');

  const params: [string, string][] = [
    ['kid', kid], ['url', productUrl], ['cache', 'true'], ['cache_ttl', String(cacheTtl)],
  ];
  // Canonical query: encode each name and value, sort by encoded name, join with '&'.
  const canonical = params
    .map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(v)] as const)
    .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
    .map(([k, v]) => `${k}=${v}`)
    .join('&');
  const sig = createHmac('sha256', secret)
    .update(`GET\n/v1/og/from-url\n${canonical}`)
    .digest('base64url');
  return `https://api.shotvik.com/v1/og/from-url?${canonical}&sig=${sig}`;
}

Then, in the product page’s generateMetadata: openGraph: { images: [{ url: shotvikProductOgUrl(productUrl), width: 1200, height: 630 }] }.

  • Shotvik reads the page’s static HTML as ShotvikBot and doesn’t run JavaScript, so the product data must be in the server-rendered HTML. Server Components do that by default.
  • With the cache on, crawlers get the stored card for up to cacheTtl seconds (max 30 days), and those hits don’t count toward your quota. The price on the card is a snapshot, so pick a shorter cache time if prices change often.
  • A page without usable product data gets a plain fallback card. See the reference.

Option 3: render once at build time with POST /v1/og

For pages that rarely change, render the PNG in a build step and let Next.js serve it as a static opengraph-image.png. Your API key stays in the build environment.

// scripts/og-home.mjs (run before `next build`)
import { writeFile } from 'node:fs/promises';

const res = await fetch('https://api.shotvik.com/v1/og', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SHOTVIK_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ template: 'basic', vars: { title: 'Hello', subtitle: 'My first OG image', site: 'example.com' } }),
});
if (!res.ok) throw new Error(`Shotvik ${res.status}: ${await res.text()}`);
await writeFile('app/opengraph-image.png', Buffer.from(await res.arrayBuffer()));

Before you ship

  • Absolute URLs. Open Graph image URLs must be absolute. A Shotvik URL already is; for your own paths, set metadataBase.
  • Runtime. The signing helpers use node:crypto, so call them from the Node.js runtime (the default for pages), or port them to Web Crypto (crypto.subtle, HMAC SHA-256).
  • Secrets. Don’t prefix SHOTVIK_SIGNING_SECRET or SHOTVIK_API_KEY with NEXT_PUBLIC_.
  • Test the result. View the page source, open the og:image URL, and use the platform tools in our link preview checklist to refresh cached previews.

Related: OG images without Vercel · Astro guide · Signed URL examples in JavaScript, Python and PHP

Try Shotvik on your own pages

Free plan: 100 renders a month, no card. Or look at real output first, no account needed.