Generating Dynamic OG Images in Next.js Without @vercel/og
Open Graph images (the preview image when you share a URL on social media) should be dynamic — showing the actual page title. Here's how to do it in Next.js without relying on a heavy library.
The Goal
When someone shares toolzip.app/tools/image-compress, the OG image should say "Image Compress — ToolZip" instead of a generic site logo.
Option 1: Static OG Images Per Page
The simplest approach: generate images at build time.
// In your page component
export const metadata = {
openGraph: {
title: 'Image Compress — ToolZip',
images: [{ url: '/og/image-compress.png', width: 1200, height: 630 }],
},
};
This works but requires generating and committing hundreds of images.
Option 2: Canvas-Based OG Image API
A serverless function that generates images on demand:
// /app/api/og/route.ts
import { NextRequest } from 'next/server';
export async function GET(req: NextRequest) {
const title = req.nextUrl.searchParams.get('title') ?? 'ToolZip';
// Create canvas
const { createCanvas } = await import('@napi-rs/canvas');
const canvas = createCanvas(1200, 630);
const ctx = canvas.getContext('2d');
// Background gradient
const grad = ctx.createLinearGradient(0, 0, 1200, 630);
grad.addColorStop(0, '#6366f1');
grad.addColorStop(1, '#ec4899');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 1200, 630);
// Title text
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 64px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(title, 600, 280);
// Site name
ctx.font = '32px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText('toolzip.app', 600, 360);
const buffer = canvas.toBuffer('image/png');
return new Response(buffer, {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
});
}
Using the OG Image
// In each tool page
export function generateMetadata({ params }) {
const tool = getToolById(params.id);
return {
openGraph: {
images: [`/api/og?title=${encodeURIComponent(tool.name)}`],
},
};
}
Caching
OG images should be cached aggressively — they rarely change. The Cache-Control: public, max-age=86400 header caches them for 24 hours at the CDN level.
Text Wrapping
Long titles need wrapping. Canvas doesn't do this automatically:
function wrapText(ctx, text, maxWidth, lineHeight, x, y) {
const words = text.split(' ');
let line = '';
let currentY = y;
for (const word of words) {
const test = line + word + ' ';
if (ctx.measureText(test).width > maxWidth && line) {
ctx.fillText(line, x, currentY);
line = word + ' ';
currentY += lineHeight;
} else {
line = test;
}
}
ctx.fillText(line, x, currentY);
}
All 76 tools on toolzip.app have dynamic OG images generated this way.
Top comments (0)