@vercel/og Tutorial: Dynamic Social Cards in Next.js
@vercel/og (shipped as next/og) turns a React component into a 1200×630 PNG at the edge, so every page can have a branded, per-title social card without a designer in the loop. You write JSX with inline styles, return an ImageResponse, and point og:image at the route. This guide is the working route, the spec it has to hit, fonts, caching, and the gotchas that catch people on the first try.
What is @vercel/og?
@vercel/og is a library that renders an HTML/CSS subset to an image inside a Vercel Function. Under the hood it uses Satori to convert your JSX into SVG and then rasterizes that to PNG. In a Next.js App Router project you import it as next/og — there is no separate package to install. You return an ImageResponse from a route handler (or from a file-based opengraph-image.tsx), and the function runs on demand, generating the card the first time a URL is requested.
The payoff is dynamic cards. A static OG image is fine for a homepage; a blog with hundreds of posts needs the title, author, and date baked into each one. Generating that at request time is exactly what this library is for.
How do you build a @vercel/og route, step by step?
Here is a complete, dynamic route. Drop it at app/api/og/route.tsx.
1. Create the route and return an ImageResponse
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
return new ImageResponse(
(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
background: "#0b0b0f",
color: "#f5f5f4",
fontSize: 64,
}}
>
Hello, card
</div>
),
{ width: 1200, height: 630 }
);
}
2. Hit the 1200×630 spec
The width: 1200, height: 630 you pass to the constructor is the whole game. That is a 1.91:1 ratio, the universal Open Graph size — see the 2026 social media image size cheat sheet for the per-platform breakdown. Keep your logo and headline inside a ~60px safe-zone margin so nothing critical gets clipped when a platform crops the edges.
3. Read the title from the query string
Make it dynamic by parsing searchParams:
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") ?? "Untitled";
Render {title} inside your layout. Now /api/og?title=My%20Post produces a card for that exact post.
4. Load a custom font
System fonts do not exist in the rendering environment. Fetch the font file and pass it in:
const font = await fetch(
new URL("./Spectral.ttf", import.meta.url)
).then((res) => res.arrayBuffer());
return new ImageResponse(<Card title={title} />, {
width: 1200,
height: 630,
fonts: [{ name: "Spectral", data: font, style: "normal" }],
});
5. Wire og:image to the route
In the page's metadata, point og:image at the absolute HTTPS URL of your route:
export const metadata = {
openGraph: {
images: [`https://yoursite.com/api/og?title=${encodeURIComponent(title)}`],
},
};
A relative path is the single most common reason a preview comes back blank. If yours does, work through why your link preview is broken.
What CSS does @vercel/og actually support?
This is where people get stuck. Satori implements a flexbox-only subset of CSS — not the whole language. The differences that bite first:
| Feature | Standard CSS | @vercel/og (Satori) |
|---|---|---|
| Layout model | flex, grid, block, float | flexbox only |
display on multi-child elements | optional | required (flex or none) |
| Fonts | system fonts available | must be loaded explicitly |
| Units | full range | px, %, em, rem, vw, vh |
| Styling | classes / stylesheets | inline style only (no Tailwind classes) |
| Images | any source | absolute URLs or data URIs |
How is a @vercel/og image cached?
By default an ImageResponse is cached aggressively — Vercel serves it from the edge after the first generation, so you pay the render cost once per unique URL, not once per visitor. Because the title is in the query string, each post gets its own cached entry. When you need to control this, set the headers explicitly:
return new ImageResponse(<Card title={title} />, {
width: 1200,
height: 630,
headers: {
"Cache-Control": "public, max-age=86400, immutable",
},
});
Two practical notes. First, watch the output file size — a generated PNG can be heavier than a compressed JPEG, and you want it loading fast.
Second, dynamic content does not regenerate a cached card. If you change a card's design or a post's title, bust the cache (a version query param works) or the old image keeps serving.
Why bother generating cards dynamically?
Because the card is doing measurable work the moment a link is shared. Beyond the impression lift above, the content of the card moves the needle.
For the meta tags the card has to sit behind, read what Open Graph is and the full og: tag reference. The route above is the rendering layer; the tags are the wiring.
If you would rather not maintain a Satori layout, fonts, and a cache strategy by hand, Social Card Studio generates branded 1200×630 cards for every Ghost or WordPress post automatically — the same dynamic-card outcome, without the route.
The one-line takeaway
Export an ImageResponse at 1200×630, build the layout with flexbox and inline styles, load your fonts explicitly, read the title from the query string, and point og:image at the absolute HTTPS route URL. That is a dynamic, branded social card for every page on the site.
Frequently asked questions
What size should a @vercel/og image be?
1200×630 pixels (a 1.91:1 ratio). Pass width: 1200 and height: 630 to the ImageResponse constructor. That single size renders correctly on Facebook, LinkedIn, Discord, and as an X summary_large_image card.
Does @vercel/og support all of CSS?
No. @vercel/og renders with Satori, which supports a flexbox-only subset of CSS. Every element needs display: flex (or none) when it has multiple children, there is no grid or float, and a limited set of properties is honored. Build layouts with flexbox and inline styles.
Can I use custom fonts with @vercel/og?
Yes. Fetch the font file as an ArrayBuffer and pass it in the fonts array of ImageResponse options. System fonts are not available in the rendering environment, so any non-default typeface must be loaded explicitly.