Dynamic OG Images in Next.js App Router: A Clean Solo Setup
Part of Building This Site, Making Money From a Small Site and Small Business Websites
By Paul Peery · August 22, 2026 · 4 min read

You do not need to pay $30 a month for an external screenshot API, and you definitely do not need to open Figma to manually export a PNG every time you publish an article. Next.js has native Open Graph generation built directly into the framework using @vercel/og and the ImageResponse constructor.
When I audited what I actually pay to run a one-person web business, third-party screenshot services were an easy target to cut. Generating cards natively on your own domain is faster, completely free, and eliminates an external dependency that can break or rate-limit you.
Here is how to set up dynamic, branded Open Graph images cleanly in the Next.js App Router without bloating your code.
File conventions beat custom route handlers
You can generate social images in Next.js through two patterns: a standalone API route handler (app/api/og/route.tsx) that reads query parameters, or a metadata file convention (app/blog/[slug]/opengraph-image.tsx) co-located right next to your post.
For a content site or micro SaaS, the file convention wins every time. When you place opengraph-image.tsx inside a dynamic route folder, Next.js does three things automatically:
- It injects the correct
<meta property="og:image">and<meta name="twitter:image">tags into your page head. - It appends an automatic hash to the image URL for rock-solid cache busting whenever you deploy changes.
- It passes your route parameters directly into the file, so you can fetch your post title and metadata using your standard data layer.
You avoid writing manual URL query strings or keeping track of image paths across different layouts.
Satori is not a full browser engine
Under the hood, ImageResponse uses Vercel's Satori library to convert HTML and CSS into SVG, which is then compiled into a lightweight PNG. It is blisteringly fast because it does not spin up a heavy headless Chromium instance.
The catch is that Satori only supports a subset of CSS. If you try to use CSS Grid, complex float layouts, or unsupported CSS properties, your build will fail or render a blank card.
Two layout rules keep your templates from breaking:
- Flexbox only: Every container should explicitly declare
display: 'flex'. Satori defaults to flex layout, and nested elements behave best when you controlflexDirection,alignItems, andjustifyContentexplicitly. - Inline styles or basic Tailwind: Keep your styling simple. Stick to padding, absolute positioning, borders, border-radii, typography, and solid background colors or linear gradients.
Much like drawing clear boundaries between Server Components vs. Client Components, understanding what Satori can and cannot render saves you hours of head-scratching.
The minimal, production-ready implementation
Here is the exact pattern I use for dynamic blog routes. Place this file at app/blog/[slug]/opengraph-image.tsx:
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'Article preview image'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
type Props = {
params: Promise<{ slug: string }>
}
export default async function Image({ params }: Props) {
const { slug } = await params
// Replace this with your actual database or CMS call
const post = await getPostBySlug(slug)
const title = post?.title || 'EMPEERYAL'
const category = post?.category || 'Article'
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
backgroundColor: '#0f172a',
padding: '64px',
color: '#f8fafc',
fontFamily: 'sans-serif',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
backgroundColor: '#38bdf8',
color: '#0f172a',
padding: '6px 16px',
borderRadius: '9999px',
fontSize: 20,
fontWeight: 700,
textTransform: 'uppercase',
}}
>
{category}
</div>
<span style={{ fontSize: 22, color: '#94a3b8' }}>empeeryal.com</span>
</div>
<div
style={{
fontSize: 54,
fontWeight: 800,
lineHeight: 1.15,
letterSpacing: '-0.02em',
maxWidth: '1000px',
}}
>
{title}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderTop: '1px solid #334155',
paddingTop: '24px',
color: '#94a3b8',
fontSize: 20,
}}
>
<span>Paul Peery</span>
<span>Read full article →</span>
</div>
</div>
),
{ ...size }
)
}
Because this file exports alt, size, and contentType, Next.js pairs them up with the page metadata automatically without requiring any extra configuration in your page.tsx.
The honesty check: font loading and edge limits
Custom typography is where dynamic image generation usually bites developers.
By default, Satori falls back to system sans-serif fonts unless you pass custom font data in the ImageResponse options. Loading custom fonts requires fetching the font file as an ArrayBuffer. If you fetch TTF files from a remote CDN inside your image handler, every cold start takes an unnecessary network hit, and if that CDN has a hiccup, your social cards fail.
Furthermore, if you run on the edge runtime, your function bundle has a strict size limit (often 500KB for assets). Loading three different weights of an un-subsetted font family can easily blow past that limit.
Keep your assets lean. If you want custom typography, store a single, subsetted .woff or .ttf file locally in your project and load it using fs.readFile (if on Node runtime) or bundle it directly. If you want bulletproof reliability with zero cold-start delay, high-quality system font stacks work remarkably well on social feeds.
The one-paragraph solo setup
Skip external card services and don't overcomplicate your layout. Create an opengraph-image.tsx file inside your dynamic route folder, pull your post title directly from your existing data query, construct a clean Flexbox layout with a dark or high-contrast background, and let Next.js handle the edge rendering and caching automatically. You get instant, branded social cards across Twitter, LinkedIn, and iMessage without spending a dime or maintaining extra infrastructure.
Keep reading
All postsComments
No comments yet — be the first!
