Next.js OG images: the static file, the dynamic route, and the metadataBase trap
Next.js has built-in support for OG images, and it works well once three settings are right. Most broken previews on Next.js sites come from the same two mistakes.
Simplest way: put a 1200 × 630 opengraph-image.png in your app/ folder, set metadataBase: new URL('https://yourdomain.com') in the root layout's metadata, and add twitter: { card: 'summary_large_image' }.
Next.js then writes the og:image tags with an absolute URL, width and height. For per-page images (blog posts, product pages), use an opengraph-image.tsx that returns an ImageResponse.
Everything here is for the App Router (the app/ folder), Next.js 13.3 and later. The Pages Router is covered at the end. Check the docs for your exact version: details such as params being a Promise changed in Next.js 15.
Option 1: a static image file
Drop an image named opengraph-image into a route folder and Next.js does the rest:
app/
├─ layout.tsx
├─ opengraph-image.png ← used for every page without its own
├─ opengraph-image.alt.txt ← optional: the og:image:alt text
└─ pricing/
└─ opengraph-image.png ← used for /pricing and below
Next.js adds og:image, og:image:type, og:image:width and og:image:height automatically, and puts a content hash in the URL so a new image isn't hidden by caches. .jpg, .jpeg, .png and .gif are supported. A twitter-image.png next to it sets twitter:image, but you only need it if X should get a different picture.
The image itself should be 1200 × 630, JPG or PNG, under 1 MB. The OG image size guide explains why and what each platform crops; Launchpack makes one from your name, tagline and a screenshot.
metadataBase: the setting that breaks previews
Crawlers need absolute image URLs. Next.js builds them from metadataBase. Set it once in the root layout:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
title: 'Your Product: what it does',
description: 'One sentence on who it is for and why.',
openGraph: { siteName: 'Your Product', type: 'website' },
twitter: { card: 'summary_large_image' },
}
Without it, Next.js warns during the build and falls back to another host: http://localhost:3000 in development, and on Vercel a deployment URL from the environment. That's where the classic bug comes from:
- Preview deployments with Vercel Authentication on. The og:image points to a protected
*.vercel.appURL, the crawler gets a login page, and the preview shows no image. Your production domain works; shared preview links don't. - The image points to the wrong domain after you add a custom domain, because the fallback still uses the old one.
Setting metadataBase to your real domain fixes both. If you want preview deployments to point at themselves, build the URL from an environment variable you control instead of relying on the fallback.
Getting the large card on X
X reads your Open Graph image, but the card type comes from twitter:card. Depending on the version and which images you define, Next.js may or may not infer the large card, so set it explicitly: twitter: { card: 'summary_large_image' } in the root metadata, as above. Without the large card you may get a small square thumbnail.
Option 2: generated images with ImageResponse
For pages that each need their own image (blog posts, docs, user profiles), replace the static file with opengraph-image.tsx. It renders JSX to a PNG:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
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 // Next.js 15+: params is a Promise
const post = await getPost(slug)
return new ImageResponse(
(
<div style={{
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: 80, background: '#0c0c10', color: '#fff',
}}>
<div style={{ fontSize: 28, color: '#f2541b' }}>yourdomain.com/blog</div>
<div style={{ fontSize: 68, fontWeight: 800, lineHeight: 1.1, marginTop: 24 }}>
{post.title}
</div>
</div>
),
{ ...size }
)
}
Things that trip people up with ImageResponse:
- Only flexbox layout. It's rendered by Satori, which supports a subset of CSS: flexbox, absolute positioning, borders, gradients, most text styles. No CSS grid, and every
divwith more than one child needsdisplay: 'flex'. - Fonts must be loaded by you and passed in the
fontsoption as anArrayBuffer. TTF, OTF and WOFF work; WOFF2 doesn't. Load only the weights you use. - Size limits. Fonts, images and code together have a bundle limit (500 KB on the Edge runtime). Large fonts or embedded images are the usual cause of build errors.
- Keep the safe zone. Leave about 60 px padding: X crops a little from the top and bottom.
Images without dynamic data are generated at build time. Routes with dynamic params are generated on the first request and cached. If you change the design, the hash in the URL changes with it, so platforms fetch the new image for new shares.
Pages Router
In the pages/ folder there are no metadata files. Add the tags with next/head, with an absolute URL:
import Head from 'next/head'
<Head>
<meta property="og:title" content="Your Product: what it does" />
<meta property="og:image" content="https://yourdomain.com/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
</Head>
Put the image in public/. For generated images, the @vercel/og package provides the same ImageResponse in an API route.
Checking it works
- Build and run production locally (
next build && next start), then view the page source: theog:imageURL should start with your real domain. - Open that image URL directly. It should be a 1200 × 630 PNG or JPG, not a login page.
- Deploy, then paste the production URL into the link preview checker to see the X, LinkedIn and Slack previews.
- Still wrong? It's probably a cache or one of the causes in OG image not showing.
Need the image and the icons?
Launchpack makes the 1200 × 630 opengraph-image, favicon.ico, apple-touch-icon and manifest icons in one ZIP. Drop them into app/ and public/.
Questions
How do I add an OG image in Next.js?
In the App Router, put a 1200 × 630 opengraph-image.png in the app/ folder (or any route folder) and set metadataBase to your domain in the root layout. Next.js generates the og:image tags with width, height and an absolute URL.
Why is my Next.js OG image not showing?
Usually metadataBase is missing, so the image URL points to localhost or a protected Vercel preview deployment. Set metadataBase: new URL('https://yourdomain.com') in the root layout and add twitter: { card: 'summary_large_image' }.
What is metadataBase in Next.js?
The base URL Next.js uses to turn relative metadata URLs, such as image paths, into absolute ones. Social crawlers need absolute URLs, so set it to your production domain.
Can I use Tailwind or CSS grid in ImageResponse?
Not CSS grid. ImageResponse is rendered by Satori, which supports flexbox and a subset of CSS through inline styles; Tailwind classes work only through the experimental tw prop. Use inline style objects with display: 'flex'.
Where do I put the favicon in a Next.js app?
Put favicon.ico in the app/ folder, and icon.png and apple-icon.png next to it if you want PNG and Apple icons; Next.js adds the link tags. The manifest goes in app/manifest.ts or public/.