Next.js 16 Open Graph Images in the App Router: PNG Templates + Raw Cover Images (GIF Support)
6 min read

Security Advisory déjà vu — and then I touched the front-end template
“Security Advisory: React2Shell & two new vulnerabilities” — you couldn’t scroll for five minutes without seeing it.
At the same time, I had fresh feedback from my own new checkup tool. So… perfect timing to revisit the front-end template.
I started with the classic, old-school setup:
app/api/og-image/route.ts- plus wiring everything from
generateMetadata()
While trying to make that “nice”, I ended up discovering (and switching to) a more App Router-native approach: opengraph-image.tsx.
This convention is still relatively new-ish, and there aren’t many practical writeups that cover the annoying parts (multiple images, correct MIME types, dynamic alt text, etc.). So I’m writing this down as a reference—maybe it saves someone a couple of hours.
Small note: this is a personal blog. Sometimes I say “we”. There is no team. It’s just me… and a few different versions of me arguing in my head.
What I wanted (real-world constraints)
- Static pages (home / listing / about / legal) should have a clean, branded OG image.
- Post pages should use the real cover image.
- Covers can be jpeg/webp/gif.
- If the cover is GIF, I want the actual GIF to be served (not a rasterized preview).
- If I later enable multiple OG images, portrait assets inside
images[]should not get cropped.
There’s also a constraint that only becomes obvious once you try to keep things “clean”:
- I don’t want the template code to pull in Node-only dependencies.
I originally wanted these OG image routes to run on Edge. In this project, though, setting runtime = "edge" on metadata image files made some routes lose the static/metadata behavior I relied on (e.g. segments that also use generateStaticParams() / metadata patterns). So I keep the implementation edge-safe, but I run the actual OG image routes on Node.js for predictable behavior.
Why opengraph-image.tsx instead of app/api/og-image/route.ts?
The short version: less plumbing, fewer moving parts.
opengraph-image.tsx is a first-class file convention in the App Router. Drop it into a route segment and Next wires the metadata for that segment automatically.
You’re also not locked into templating. You can return either:
- a PNG rendered by
ImageResponse(great for templates) - or raw bytes via
Response(perfect for “serve the real image”)
The architecture (three buckets, because reality)
My setup ended up being a mix of templates, fallbacks, and raw post covers—with one practical adjustment: OG image files run on Node.js in this project, even though the template layer itself is kept Edge-safe.
Bucket A — Static pages: one reusable PNG template
Home, about, legal pages… they usually don’t need real photos. A branded OG card is enough:
- logo
- title + subtitle
- optional badge (route label)
So I render a template with ImageResponse.
Crucially:
- the route runtime is
nodejs(practical compatibility in this setup) - the template implementation still imports no Node-only dependencies (so it remains portable if/when Edge becomes viable)
Bucket B — “Semi-dynamic” listings: template + params (tags)
Some pages are dynamic but still don’t have a natural “cover image” — e.g. /tags/[tags].
For those I keep it template-first, but let the route param influence:
- the subtitle (“Posts with tags: …”)
- the badge (
/tags/foo,bar) generateImageMetadata()id/alt
This is the sweet spot when you want dynamic context, but you don’t want to fetch/compose real imagery.
Bucket C — Posts: raw cover output (GIF preserved) + fallback to template
For actual posts, the cover image is usually the best OG image.
- If the cover is GIF → return raw
image/gifbytes. - Otherwise → normalize to
1200×630JPEG using sharp. - If there’s no cover (or it’s placeholder) → fallback to the same PNG template using title/description.
And because sharp is involved, the post OG route runs on the Node.js runtime.
One annoying detail in my project: flat slugs
In my KODKAFA template, categories and posts share the same slug directory (a flat structure). That means some pages don’t have images by design.
So the rule is simple:
- No image? → fallback template using title + description.
- If a category/listing is fully static (like a curated page) → use the static template directly.
- If it’s semi-dynamic (like tags) → template + params.
This keeps the behavior boring and predictable:
- templates for “pages that don’t have assets”
- real covers for “pages that actually have assets”
Code
1) Static template helper (PNG), token-friendly
I’m using shadcn, and my theme system is driven by tokens.
But ImageResponse wants simple, explicit values.
So instead of re-parsing CSS variables (or doing runtime file reads), I keep generated brand exports like:
DEFAULT_BG_RGBADEFAULT_FG_RGBA
…and I use those directly inside the template.
File: lib/og.templates.tsx
import { BASE_URL, OG_IMAGE_SIZE, SHORT_NAME, SITE_NAME } from '@/config';import { getRgbaWithOpacity } from '@/lib/color.util';import { DEFAULT_BG_RGBA, DEFAULT_FG_RGBA } from '@/lib/generated/brand';import { renderLogoImage } from '@/lib/og.utils';import { getLogoDataUri } from '@/lib/svg.utils';import type { ReactElement } from 'react';export type StaticOgTemplateInput = {bgColor?: string;textColor?: string;fillColor?: string;title: string;subtitle?: string;badge?: string;};export function renderStaticOgTemplate(input: StaticOgTemplateInput): ReactElement {// Set default colors (already in RGBA format from generated)const bgColor = input.bgColor || DEFAULT_BG_RGBA;const textColor = input.textColor || DEFAULT_FG_RGBA;const fillColor = input.fillColor || textColor;// Use generated SVG with runtime fillColorconst logoSrc = getLogoDataUri(fillColor);const title = input.title?.trim() || SITE_NAME;const subtitle = input.subtitle?.trim();const badge = input.badge?.trim();return (<divstyle={{width: OG_IMAGE_SIZE.width,height: OG_IMAGE_SIZE.height,display: 'flex',flexDirection: 'column',justifyContent: 'space-between',padding: '56px',background: bgColor,color: textColor,}}><div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>{renderLogoImage(logoSrc, { width: 360, alt: `${SITE_NAME} - Logo` })}{badge ? (<divstyle={{display: 'flex',alignItems: 'center',padding: '10px 14px',borderRadius: 999,background: getRgbaWithOpacity(bgColor, 0.08),border: `1px solid ${getRgbaWithOpacity(textColor, 0.14)}`,fontSize: 22,fontWeight: 700,letterSpacing: 0.2,}}>{badge}</div>) : null}</div><div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}><divstyle={{display: 'flex',fontSize: 64,fontWeight: 900,lineHeight: 1.05,letterSpacing: -0.6,maxWidth: 1040,wordBreak: 'break-word',}}>{title}</div>{subtitle ? (<divstyle={{display: 'flex',fontSize: 28,fontWeight: 600,lineHeight: 1.25,color: getRgbaWithOpacity(textColor, 0.78),maxWidth: 1040,wordBreak: 'break-word',}}>{subtitle}</div>) : null}</div><divstyle={{display: 'flex',alignItems: 'center',justifyContent: 'space-between',fontSize: 22,color: getRgbaWithOpacity(textColor, 0.62),}}><span>{BASE_URL}</span><span>{SHORT_NAME}</span></div></div>);}
The nice side effect here:
- the template is synchronous
- no waiting on SVG fetch
- no filesystem reads
If the logo is already code-generated as a data URI builder, the whole thing stays “pure React”.
2) Static pages usage (home)
File: app/opengraph-image.tsx
import { DEFAULT_DESCRIPTION, OG_IMAGE_SIZE, SITE_NAME } from '@/config';import { renderStaticOgTemplate } from '@/lib/og.templates';import { ImageResponse } from 'next/og';// ⚠ Using edge runtime on a page currently disables static generation for that page.export const runtime = 'nodejs';export const contentType = 'image/png';export default async function Image() {const element = await renderStaticOgTemplate({title: SITE_NAME,subtitle: DEFAULT_DESCRIPTION,badge: '/',// Optional overrides: when you want a consistent OG theme (e.g. always dark)bgColor: 'rgba(9, 9, 11, 1)',textColor: 'rgba(250, 250, 250, 1)',fillColor: 'rgba(250, 250, 250, 1)',});return new ImageResponse(element, { ...OG_IMAGE_SIZE });}
The point isn’t that it’s dark.
The point is:
- OG styling becomes a deliberate choice
- it’s not dependent on “current theme mode”
- it doesn’t require parsing CSS at runtime
3) Semi-dynamic usage (tags)
File: app/tags/[tags]/opengraph-image.tsx
import { ImageResponse } from "next/og";import { OG_IMAGE_SIZE, SITE_NAME } from "@/config";import { renderStaticOgTemplate } from "@/lib/og.templates";export const runtime = "nodejs";export const contentType = "image/png";// Edge runtime is not supported with `generateStaticParams` because it requires a dynamic route.export async function generateImageMetadata({params,}: {params: Promise<{ tags: string }>;}) {const { tags } = await params;const tagsArray = tags?.split(",") || [];return [{id: String(tagsArray.join("-") || "default"),alt: `${SITE_NAME} - Tags: ${tagsArray.join(", ")}`,contentType: "image/png",...OG_IMAGE_SIZE,},];}export default async function Image({id,params,}: {id: Promise<string>;params: Promise<{ tags: string }>;}) {await id;const { tags } = await params;const tagsArray = tags?.split(",") || [];const element = await renderStaticOgTemplate({title: SITE_NAME,subtitle: `Posts with tags: ${tagsArray.join(", ")}`,badge: `/tags/${tags}`,});return new ImageResponse(element, { ...OG_IMAGE_SIZE });}
The “semi-dynamic” trick is simple:
- metadata uses only params
- template uses only params
No API call.
No extra cache layers.
Still feels personalized.
4) Image processor (cover vs contain + background)
The rules:
- cover → cover fit (cropping is acceptable)
- gallery images → contain fit (portrait-friendly, no crop)
- fill the empty space with
bgColor - GIF → keep it as-is (
image/gif)
The important idea (with the caveat above):
- the template code is kept Edge-compatible (no Node-only imports)
- the OG image routes run on Node.js for predictable static/metadata behavior in this setup
- cover processing still runs on Node.js because it uses
sharp
So the only “hard” Node dependency stays isolated to the places that truly need it. citeturn0search11
5) Post OG: raw bytes + optional multiple outputs
This is the workhorse.
The moving parts
- I fetch post + images and cache it using
unstable_cache. - I tag the cache entry with
og:post:${slug}so my CMS can revalidate it on demand. - I build an image index:
cover→fit: coverimages[]→fit: contain
- If there’s no usable image, I fall back to the static template using
title + description.
File: app/posts/[slug]/opengraph-image.tsx
import { DEFAULT_BG_COLOR, DEFAULT_DESCRIPTION, OG_IMAGE_SIZE, SITE_NAME } from '@/config';import type { PostDto } from '@/kodkafa/schemas';import { postsControllerFindOneBySlug } from '@/kodkafa/ssr/posts/posts';import { postsControllerFindOneBySlugResponse } from '@/kodkafa/zod/kodkafaApi.zod';import { getApiDomain } from '@/lib/api/domain';import { fetchAndValidate } from '@/lib/api/fetch-and-validate';import { getImages } from '@/lib/image.utils';import { fetchImageBuffer, isGifUrl, processImageForOg } from '@/lib/og.image-processor';import { renderStaticOgTemplate } from '@/lib/og.templates';import { unstable_cache } from 'next/cache';import { ImageResponse } from 'next/og';export const runtime = 'nodejs';const size = OG_IMAGE_SIZE;const OG_MULTIPLE = true;type ImgLike = { src?: string | null; altText?: string | null };type OgPost = {slug: string;title: string;description: string;cover: ImgLike | null;images: ImgLike[];};export function getOgPost(slug: string) {const cached = unstable_cache(async (): Promise<OgPost> => {const domain = getApiDomain();const post = await fetchAndValidate<PostDto>({fetcher: () => postsControllerFindOneBySlug(domain, slug),schema: postsControllerFindOneBySlugResponse,context: 'Post for OG',defaultData: { title: '404 - Post not found' } as PostDto,});const { cover, images } = getImages(post) as { cover?: ImgLike; images?: ImgLike[] };return {slug: `/${slug}`,title: (post?.title || SITE_NAME || '').trim(),description: (post?.description || DEFAULT_DESCRIPTION || '').trim(),cover: cover ?? null,images: Array.isArray(images) ? images : [],};},['og-post-by-slug-v1', slug],{tags: [`og:post:${slug}`],revalidate: false,});return cached();}function isEmptyCover(src?: string | null) {return !src || src === '/placeholder.jpg';}async function fallbackTemplate(og: OgPost) {const element = await renderStaticOgTemplate({title: og.title || SITE_NAME,subtitle: og.description || DEFAULT_DESCRIPTION,badge: og.slug || '/',});return new ImageResponse(element, { ...size });}function normalizeList(images: ImgLike[]): ImgLike[] {return images.filter((x) => !!x?.src).map((x) => ({src: x!.src!,altText: (x?.altText || '').trim() || null,}));}function buildImageIndex(og: OgPost) {const coverSrc = og.cover?.src ?? null;const coverAlt = (og.cover?.altText || og.title || SITE_NAME || '').trim();const list = normalizeList(og.images);const index: Record<string, { src: string; alt: string; fit: 'cover' | 'contain' }> = {};if (coverSrc && !isEmptyCover(coverSrc)) {index.cover = { src: coverSrc, alt: coverAlt, fit: 'cover' };}if (OG_MULTIPLE) {list.forEach((img, i) => {const id = `img-${i}`;const alt = (img.altText || og.title || SITE_NAME || '').trim();index[id] = { src: img.src!, alt, fit: 'contain' };});}return index;}export async function generateImageMetadata({params,}: {params: Promise<{ slug: string }>;}) {const { slug } = await params;const og = await getOgPost(slug);const index = buildImageIndex(og);const ids = Object.keys(index);// No cover/images → expose a template IDif (ids.length === 0) {return [{id: 'template',alt: og.title || SITE_NAME,contentType: 'image/png',size: { width: size.width, height: size.height },},];}return ids.map((id) => {const item = index[id];const contentType = isGifUrl(item.src) ? 'image/gif' : 'image/jpeg';return {id,alt: item.alt,contentType,size: { width: size.width, height: size.height },};});}export default async function Image({id,params,}: {id: Promise<string>;params: Promise<{ slug: string }>;}) {const imageId = await id;const { slug } = await params;const og = await getOgPost(slug);const index = buildImageIndex(og);// Handle template fallbackif (imageId === 'template') {return fallbackTemplate(og);}const selected = index[imageId];if (!selected) {return fallbackTemplate(og);}try {const input = await fetchImageBuffer(selected.src, { timeoutMs: 7000 });// Preserve GIF bytesif (isGifUrl(selected.src)) {return new Response(input as unknown as BodyInit, {status: 200,headers: { 'Content-Type': 'image/gif' },});}const { buffer, mimeType } = await processImageForOg(input, size, {fit: selected.fit,background: DEFAULT_BG_COLOR,quality: 90,});return new Response(buffer as unknown as BodyInit, {status: 200,headers: { 'Content-Type': mimeType },});} catch {return fallbackTemplate(og);}}
A few subtle wins here:
- Dynamic alt text that matches the page
- MIME correctness: GIF stays
image/gif, everything else becomes predictableimage/jpeg - No accidental portrait cropping in
images[]thanks tocontain - Clean separation:
- templates for static + semi-dynamic pages
- processed covers (with sharp) for posts
- A clean
generateMetadata()surface area
Twitter cards
If you don’t need a different image for Twitter, opengraph-image.tsx is enough — Next will use it for Twitter cards as well.
If you do want something Twitter-specific (different crop, different text hierarchy, etc.), add a sibling twitter-image.tsx next to it.
Same idea, same rules — just a separate output.
What this actually fixed for me
- Dynamic alt text that matches the page
- MIME correctness: GIF stays
image/gif, everything else becomes predictableimage/jpeg - No accidental portrait cropping in
images[]thanks tocontain - A clean separation:
- templates for static + semi-dynamic pages
- raw covers (with sharp) for posts
- Clean
generateMetadata()— no image wiring inside metadata. File conventions handle it.
References
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata
- https://nextjs.org/docs/app/api-reference/functions/generate-metadata
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata/twitter-image