OG Image Studio
Design Open Graph social cards with live preview and downloadable output using Next.js ImageResponse.
Run this experiment yourself
Demos are not embedded on this site. Deploy a standalone copy on Vercel or run the experiment app locally.
Local development
cd apps/experiments/og-image-studio pnpm install pnpm dev
Then open http://localhost:3010.
This is an experimental demo. Use it as a starting point for your own projects.
OG Image Studio is a small design tool for Open Graph social cards. You edit a title, description, theme, layout, and canvas size in the browser, and a live preview is rendered by a Route Handler (GET /api/og/studio) that returns a PNG using Next.js ImageResponse from next/og. The query string is validated with Zod, so the same URL you preview is the URL you can put in an og:image meta tag.
It teaches how dynamic social images work: JSX and a subset of CSS are rendered to an image on the server, parameters travel in the URL, and validation keeps a public image endpoint from being abused.
Features
- Live preview – every change rebuilds the image URL and the preview
<img>reloads from/api/og/studio. - Three layouts –
default(top-aligned text with a site label),centered, andsplit(title in a left column, description on the right). - Light and dark themes – fixed colour palettes for each theme.
- Custom canvas size – width 400–1200 and height 200–630 (defaults 1200 × 630, the standard Open Graph size).
- Downloadable output – a Download image link saves the same URL as
og-image.png. - Strict validation – invalid input returns a
400JSON response with Zod field errors instead of an image. - No remote assets – text only; the route never fetches external URLs.
API Reference
GET /api/og/studio
Render a PNG Open Graph image from query parameters.
Prop
Type
Unknown parameters are ignored. The demo adds a v cache-busting parameter that the route does not read.
Success – 200 with an image response generated by ImageResponse at the requested width and height.
curl -o card.png "http://localhost:3000/api/og/studio?title=Hello%20OG&description=Rendered%20by%20ImageResponse&theme=dark&layout=centered&width=1200&height=630"Validation error – 400 with content-type: application/json. The body is the Zod flatten() output under error:
{
"error": {
"formErrors": [],
"fieldErrors": {
"title": ["Too small: expected string to have >=1 characters"],
"width": ["Too big: expected number to be <=1200"]
}
}
}Implementation Details
Define the query schema
apps/experiments/og-image-studio/logic.ts holds the Zod schema. z.coerce.number() converts the string query parameters into integers before the range checks run.
export const ogStudioQuerySchema = z.object({
title: z.string().min(1).max(120),
description: z.string().max(240).default(''),
theme: z.enum(['light', 'dark']).default('light'),
layout: z.enum(['default', 'centered', 'split']).default('default'),
width: z.coerce.number().int().min(400).max(1200).default(1200),
height: z.coerce.number().int().min(200).max(630).default(630),
});Parse the request
The route handler turns searchParams into a plain object and runs safeParse. A failure short-circuits with a JSON 400.
export async function GET(request: NextRequest) {
const raw = Object.fromEntries(request.nextUrl.searchParams.entries());
const parsed = ogStudioQuerySchema.safeParse(raw);
if (!parsed.success) {
return new Response(JSON.stringify({ error: parsed.error.flatten() }), {
status: 400,
headers: { 'content-type': 'application/json' },
});
}
// ...
}Pick a palette
Theme selects four colours used by the JSX tree.
const isDark = theme === 'dark';
const bg = isDark ? '#0a0a0a' : '#fafafa';
const fg = isDark ? '#fafafa' : '#171717';
const muted = isDark ? '#a3a3a3' : '#737373';
const border = isDark ? '#262626' : '#e5e5e5';Render with ImageResponse
The root <div> uses flexbox only (the renderer does not support CSS grid). layout changes the flex direction and alignment; the split layout renders the title in a left column with a right border and moves the description to the right column.
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: layout === 'split' ? 'row' : 'column',
alignItems: layout === 'centered' ? 'center' : 'flex-start',
justifyContent: layout === 'centered' ? 'center' : 'flex-start',
background: bg,
color: fg,
padding: 64,
fontFamily: 'system-ui, sans-serif',
border: `1px solid ${border}`,
}}
>
{/* title / description blocks per layout */}
</div>
),
{ width, height },
);Build the preview URL
buildOgStudioUrl(origin, params) constructs the endpoint URL. The demo then appends a v counter so Refresh preview forces the browser to re-request the image.
export function buildOgStudioUrl(origin: string, params: OgStudioParams): string {
const url = new URL('/api/og/studio', origin);
url.searchParams.set('title', params.title);
url.searchParams.set('description', params.description);
url.searchParams.set('theme', params.theme);
url.searchParams.set('layout', params.layout);
url.searchParams.set('width', String(params.width));
url.searchParams.set('height', String(params.height));
return url.toString();
}Use Cases
- Generate per-page share cards for blog posts, docs, or product pages by pointing
og:imageat a parameterised route. - Prototype card designs and copy lengths before committing them to a template.
- Learn what
ImageResponsecan and cannot render (flexbox, limited CSS, system fonts by default). - Teach input validation on public image-generation endpoints.
Limitations
Security notes for this experiment:
- Remote image URLs are not fetched.
- Text inputs are validated and length-limited.
Additional limits that come from the code and Next.js:
- Text only. There is no logo, background image, or custom font upload. The route uses
fontFamily: 'system-ui, sans-serif'and nofontsoption. - Next.js
ImageResponselimits apply. Only flexbox and a subset of CSS are supported (nodisplay: grid), and the bundle for the image is limited to 500 KB. See the ImageResponse reference. - No endpoint-level rate limiting. Anyone can request images with arbitrary valid parameters; add caching or rate limits before exposing a similar route in production.
- The theme and layout are fixed. Adding new designs means editing the route's JSX.
- The downloaded file is whatever the server returns. The link uses the preview URL with
download="og-image.png"; no client-side rendering happens. - The height validation allows 200–630, so very tall cards are not supported.
Use in your project
Copy the schema and handler into your own app, then reference the route from page metadata.
// app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import { z } from 'zod';
const schema = z.object({
title: z.string().min(1).max(120),
description: z.string().max(240).default(''),
theme: z.enum(['light', 'dark']).default('light'),
});
export async function GET(request: NextRequest) {
const parsed = schema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
);
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { title, description, theme } = parsed.data;
const dark = theme === 'dark';
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 64,
background: dark ? '#0a0a0a' : '#fafafa',
color: dark ? '#fafafa' : '#171717',
}}
>
<div style={{ fontSize: 56, fontWeight: 700 }}>{title}</div>
{description ? (
<div style={{ fontSize: 28, marginTop: 24, color: '#737373' }}>{description}</div>
) : null}
</div>
),
{ width: 1200, height: 630 },
);
}// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const title = `Post: ${slug}`;
return {
title,
openGraph: {
images: [`/api/og?title=${encodeURIComponent(title)}&theme=dark`],
},
};
}Deployment
This experiment has no external services and no required environment variables, so it works on any Next.js host. On Vercel it runs as a regular Vercel Function. Use the Deploy button on this experiment page. Add any extra configuration in this experiment's .env.example / Configuration section.
Local Development
pnpm install
pnpm devOpen the experiment page, or call the route directly:
# Default card
curl -o default.png "http://localhost:3000/api/og/studio?title=Ship%20faster%20with%20Next.js"
# Split layout, dark theme, smaller canvas
curl -o split.png "http://localhost:3000/api/og/studio?title=Split%20layout&description=Two%20columns&theme=dark&layout=split&width=800&height=420"
# Trigger validation (title missing) and inspect the JSON error
curl -i "http://localhost:3000/api/og/studio?layout=centered"Configuration
None. The experiment declares no required environment variables and no external services. Defaults are defined in the Zod schema (theme: light, layout: default, 1200 × 630), and the demo's initial copy comes from the defaults object in apps/experiments/og-image-studio/demo.tsx.
Vercel / Next.js Features Used
ImageResponse(next/og) to render JSX to an image.- Route Handlers for the
GET /api/og/studioendpoint. - Metadata and OG images as the place you would reference the generated URL.
- Zod for runtime query validation.