Skip to content
Docs

OG Image Generation: The Complete Customization Guide

Learn how to use custom fonts in your Vercel OG image, plus emoji, external images, non-Latin text, dynamic titles, and article headlines.

DX Team

Copy link to headingCustomize a Vercel OG image with custom fonts

Every page on your site can have its own social card, rendered on request instead of designed by hand. @vercel/og builds that Open Graph (OG) image from JSX and CSS, and by default it returns plain text in a single bundled font. Customizing a Vercel OG image means changing the JSX you pass in, the options you set on ImageResponse, or both.

Every example below builds on the same route handler. Create app/api/og/route.tsx and paste the following code:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
// App router includes @vercel/og. No need to install it.
export async function GET() {
return new ImageResponse(
(
<div
style={{
display: 'flex',
fontSize: 60,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
Hello world!
</div>
),
{
width: 1200,
height: 630,
},
);
}

Run pnpm dev and browse to http://localhost:3000/api/og to see the rendered PNG.

Copy link to headingHow to use a custom font in your Vercel OG image

@vercel/og ships with one default font, so any other typeface has to be passed in as font data through the fonts option. You have two ways to supply it, depending on whether the font lives on Google Fonts or in your repository.

Copy link to headingLoad a font from Google Fonts at request time

Google Fonts can return a subset of a font containing only the characters you're rendering, which keeps the payload small. Fetch the stylesheet, extract the font file URL, and pass the result to ImageResponse:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
async function loadGoogleFont(font: string, text: string) {
const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`;
const css = await (await fetch(url)).text();
const resource = css.match(
/src: url\((.+)\) format\('(opentype|truetype)'\)/,
);
if (resource) {
const response = await fetch(resource[1]);
if (response.status == 200) {
return await response.arrayBuffer();
}
}
throw new Error('failed to load font data');
}
export async function GET() {
const text = 'Hello world!';
return new ImageResponse(
(
<div
style={{
display: 'flex',
backgroundColor: 'white',
height: '100%',
width: '100%',
fontSize: 100,
fontFamily: 'Geist',
paddingTop: '100px',
paddingLeft: '50px',
}}
>
{text}
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Geist',
data: await loadGoogleFont('Geist', text),
style: 'normal',
},
],
},
);
}

The text parameter on the stylesheet request is what triggers subsetting, so pass the same string you're rendering. The name you give the font has to match the fontFamily value in your styles.

Copy link to headingLoad a font file from your repository

For a licensed font or one that isn't on Google Fonts, read the file from disk once at module scope so it isn't re-read on every request:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const geistBold = await readFile(join(process.cwd(), 'assets/Geist-Bold.ttf'));
export async function GET() {
return new ImageResponse(
(
<div
style={{
display: 'flex',
backgroundColor: 'white',
height: '100%',
width: '100%',
fontSize: 100,
fontFamily: 'Geist',
alignItems: 'center',
justifyContent: 'center',
}}
>
Hello world!
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Geist',
data: geistBold,
weight: 700,
style: 'normal',
},
],
},
);
}

Bundled fonts count toward the 500KB bundle limit, which also covers your JSX, CSS, and images. A single weight of a subset font typically lands well under it.

Copy link to headingTroubleshoot failed to load font data errors

The Google Fonts helper throws failed to load font data when it can't return font data. Three causes account for nearly every instance:

  • The family name doesn't match Google Fonts: The value passed to loadGoogleFont has to match the family name on Google Fonts, with + in place of spaces, as in Playfair+Display.
  • The stylesheet returned woff2: The regex matches opentype and truetype sources only, and @vercel/og can't parse woff2 in any case. Send the request exactly as written, because the format Google Fonts returns varies with the headers you send.
  • The font file request failed: The helper only returns data on a 200 response, so a rate-limited or redirected request falls through to the error.

Log css before the regex runs to see which of the three you're hitting. The rest of the customization happens through the same ImageResponse options.

Copy link to headingHow to add emoji to an OG image

Emoji render as images rather than glyphs, so @vercel/og substitutes them from an emoji set that you choose with the emoji option. Set it alongside width and height:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
export async function GET() {
return new ImageResponse(
(
<div
style={{
display: 'flex',
fontSize: 100,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
padding: '50px 200px',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}
>
👋, 🌎
</div>
),
{
width: 1200,
height: 630,
// Supported options: 'twemoji', 'blobmoji', 'noto' and 'openmoji'
// Defaults to 'twemoji'
emoji: 'twemoji',
},
);
}

The option is global for the image, so a single route renders one emoji style throughout.

Copy link to headingHow to use an external image URL in your Vercel OG image

Any absolute image URL can go in an <img> tag, which lets you build a card around content you don't control, such as a profile picture. Read the identifier from a query parameter and interpolate it into the src:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const username = searchParams.get('username');
if (!username) {
return new ImageResponse(<>Visit with &quot;?username=vercel&quot;</>, {
width: 1200,
height: 630,
});
}
return new ImageResponse(
(
<div
style={{
display: 'flex',
fontSize: 60,
color: 'black',
background: '#f6f6f6',
width: '100%',
height: '100%',
paddingTop: 50,
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
<img
width="256"
height="256"
src={`https://github.com/${username}.png`}
style={{
borderRadius: 128,
}}
/>
<p>github.com/{username}</p>
</div>
),
{
width: 1200,
height: 630,
},
);
}

Set width and height on the <img> tag, which Satori recommends for embedded images. Handle the missing-parameter case as well, so a malformed link still returns a card instead of an error.

Copy link to headingHow to render non-Latin languages in an OG image

Non-Latin text renders from the same JSX as Latin text, with no fonts option required:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
export async function GET() {
return new ImageResponse(
(
<div
style={{
display: 'flex',
fontSize: 40,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
padding: '50px 200px',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}
>
👋 Hello 你好 नमस्ते こんにちは สวัสดีค่ะ 안녕 добрий день Hallá
</div>
),
{
width: 1200,
height: 630,
},
);
}

Right-to-left languages aren't supported by Satori, the layout engine underneath @vercel/og, so their text won't lay out correctly.

To render a script in a specific typeface rather than the default, pass the font through the fonts option. Whatever the script, the text itself changes per page, which is what URL parameters are for.

Copy link to headingHow to pass dynamic text to your Vercel OG image

A query parameter turns one route into a card for every page on your site. Read the parameter, cap its length, and fall back to a default when it's missing:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
// ?title=<title>
const hasTitle = searchParams.has('title');
const title = hasTitle
? searchParams.get('title')?.slice(0, 100)
: 'My default title';
return new ImageResponse(
(
<div
style={{
backgroundColor: 'black',
height: '100%',
width: '100%',
display: 'flex',
textAlign: 'center',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
flexWrap: 'nowrap',
}}
>
<div
style={{
fontSize: 60,
letterSpacing: '-0.025em',
color: 'white',
padding: '0 120px',
lineHeight: 1.4,
whiteSpace: 'pre-wrap',
}}
>
{title}
</div>
</div>
),
{
width: 1200,
height: 630,
},
);
} catch (e: any) {
console.log(`${e.message}`);
return new Response(`Failed to generate the image`, {
status: 500,
});
}
}

Reference it from your page metadata as /api/og?title=my%20post%20title, using an absolute URL so social platforms can fetch it. The slice(0, 100) guard matters, because an unbounded title overflows the card.

To stop anyone from generating cards with arbitrary text on your domain, see encrypting parameters.

Copy link to headingHow to show article headlines in social previews with Vercel OG

Rendering the headline inside the image keeps it readable in every link preview, regardless of how a social platform treats the page title. Combine a background image, a custom font, and a dynamic title in one route:

app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const headlineFont = await readFile(
join(process.cwd(), 'assets/headline-italic-700.ttf'),
);
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// Fetch the title and image from your database.
// They're hardcoded here.
const title = searchParams.get('title') ?? 'Your article headline';
const image = '<https://example.com/article-hero.jpg>';
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
backgroundImage: `url(${image})`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
color: 'white',
}}
>
<div
style={{
display: 'flex',
position: 'absolute',
bottom: 60,
left: 80,
fontSize: 50,
fontFamily: 'Headline',
maxWidth: 900,
whiteSpace: 'pre-wrap',
letterSpacing: -1,
}}
>
{title}
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Headline',
data: headlineFont,
weight: 700,
style: 'italic',
},
],
},
);
}

Swap the hardcoded values for a lookup against your content source, keyed on the article slug you pass in. The OG image template is a working version of this pattern if you'd rather start from a repository than build the route yourself.

Copy link to headingNext steps

With the customizations in place, the remaining work is deploying the route so social platforms can fetch the image at an absolute URL. Deploy the project this route lives in at vercel.com/new, or start from a preconfigured project in the template gallery.

Copy link to headingRelated resources

Copy link to headingFrequently asked questions

Copy link to headingWhat size should a Vercel OG image be?

The recommended size is 1200x630 pixels, which is also the default width and height on ImageResponse, so both options can be omitted at that size. The values you pass become the pixel dimensions of the returned PNG, so keep them consistent with the og:image:width and og:image:height tags in your page markup.

Copy link to headingHow do I test an OG image before sharing the link?

Open the deployment in your Vercel dashboard and select the Open Graph tab, which renders previews for Twitter, Slack, Facebook, and LinkedIn from that deployment's OG metadata. For work on the image itself, the OG Playground renders your JSX in the browser, so you can check a layout without deploying.

Copy link to headingAre Vercel OG images cached?

Yes. @vercel/og sets cache-control: public, immutable, no-transform, max-age=31536000 on every response, so the CDN serves repeat requests without re-rendering. Because that header marks the response as immutable, change the URL, typically through a query parameter, when you want a new image for the same page.

Copy link to headingWhich font formats does @vercel/og support?

The library accepts ttf, otf, and woff font data, and ttf or otf parse faster than woff. woff2 isn't supported, so a font that renders in the browser can still fail in an OG image. Convert the file to ttf before passing it in the fonts array.

Related documentation

More OG Image Generation guides