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.
paramsis a promise since Next.js 16.- A plain
opengraph-image.png,.jpgor.giffile 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.
ImageResponsedraws 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 examplePOST /v1/screenshot. - The data is on a product page.
/v1/og/from-urlreads 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
cacheTtlseconds (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_SECRETorSHOTVIK_API_KEYwithNEXT_PUBLIC_. - Test the result. View the page source, open the
og:imageURL, 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