How to dynamically generate Open Graph images with Next.js and Makerkit

In this tutorial, we'll learn how to dynamically generate Open Graph images with Next.js and Makerkit

Open Graph (OG) images are crucial for making your content stand out when shared on social media - they're the preview images that appear when your content is shared on platforms like Twitter, LinkedIn, or Facebook. In this post, I'll show you how we implemented dynamic OG image generation in Makerkit using Next.js and Sharp.

What We're Building

This website implements a system that automatically generates beautiful Open Graph images for blog posts and documentation pages. When someone shares your content on social platforms like Twitter or LinkedIn, they'll see a professionally designed preview image with:

  • The post title
  • Publication date
  • Description
  • Your website branding

In this blog post - I want to show you how we implemented this system using Next.js and Sharp.

Want to see an example?

Below is this own post's dynamic OG image generated using the code below:

You can also see it by appending /og to the end of the blog post URL.

How It Works

The implementation consists of two main parts:

  1. A Next.js API route that generates the images on-demand
  2. An SVG template that defines the image layout

Here's how to add it to your pages:

// In your blog post page
const ogImage = `${appConfig.url}/blog/${collection}/${slug}/og`;
// Add to your metadata
export const metadata = {
openGraph: {
images: [ogImage]
}
}

The Image Generation Route

The route handles the actual image generation using these steps:

  1. Fetches the post data using our CMS client
  2. Generates an SVG template with the post information
  3. Converts the SVG to a WebP image using Sharp
  4. Returns the image with proper caching headers

Generating the SVG Template

We use a custom SVG template to create a professional-looking card layout. The template is defined in the generateBlogHeaderSVG function:

apps/web/app/(marketing)/blog/[slug]/og/route.ts
const WEBSITE_NAME = 'makerkit.dev'; // replace with your website name or use from appConfig
function generateBlogHeaderSVG({
date = '',
title = '',
description = '',
} = {}) {
const textFontSize = 32;
let descriptionTop = 200;
const getTitleLines = (text: string, maxLength = 40) => {
// If no title provided, return empty array
if (!text) return [];
const words = text.split(' ');
const lines = [];
let currentLine: string[] = [];
let currentLength = 0;
words.forEach((word) => {
// Check if the current line is full or if the word is too long
if (
currentLength + word.length + 1 <= maxLength ||
currentLine.length === 0
) {
currentLine.push(word);
currentLength += word.length + 1;
} else {
// Start a new line
lines.push(currentLine.join(' '));
currentLine = [word];
currentLength = word.length;
}
});
// Add the last line if there's anything left
if (currentLine.length > 0) {
lines.push(currentLine.join(' '));
}
return lines;
};
// push the description down if the title is too long
if (title.length > 40) {
descriptionTop = 210;
}
// Helper function to safely split description into lines
const getDescriptionLines = (text: string, maxLength = 65) => {
// If no description provided, return empty array
if (!text) return [];
const words = text.split(' ');
const lines = [];
let currentLine: string[] = [];
let currentLength = 0;
words.forEach((word) => {
if (currentLength + word.length + 1 <= maxLength) {
currentLine.push(word);
currentLength += word.length + 1;
} else {
lines.push(currentLine.join(' '));
currentLine = [word];
currentLength = word.length;
}
});
if (currentLine.length > 0) {
lines.push(currentLine.join(' '));
}
return lines;
};
const titleLines = getTitleLines(title);
const descriptionLines = getDescriptionLines(description);
return `
<svg viewBox="0 0 800 400" width="100%">
<defs>
<style>
@font-face {
font-family: Inter;
src: "./fonts/Inter.ttf";
}
svg {
font-family: Inter, serif;
}
</style>
<linearGradient id="bgGradient" x1="0" y1="1" x2="1" y2="1">
<stop offset="0%" stop-color="#0a0b10" />
<stop offset="100%" stop-color="#0f1015" />
</linearGradient>
</defs>
<!-- Outer background rectangle -->
<rect
x="0"
y="0"
width="800"
height="400"
fill="#0a0b10"
/>
<!-- Inner content background -->
<rect
x="21"
y="21"
width="758"
height="358"
rx="23"
fill="url(#bgGradient)"
/>
<!-- Date -->
<text
x="60"
y="80"
fill="#71717a"
style="font-size: 16px; letter-spacing: -0.01em;"
>${date}</text>
<!-- Title -->
<text
x="60"
y="140"
fill="white"
font-size="${textFontSize}px"
style="font-weight: 600; letter-spacing: -0.02em;"
>
${titleLines.map((line, index) => `<tspan x="60" y="${125 + index * 28}">${line}</tspan>`).join('\n')}
</text>
<!-- Description -->
${descriptionLines
.map(
(line, index) => `
<text
x="60"
y="${descriptionTop + index * 28}"
fill="#a1a1aa"
style="font-size: 18px; letter-spacing: -0.01em;"
>${line}</text>
`,
)
.join('')}
<!-- Domain name -->
<text
x="60"
y="340"
fill="#71717a"
style="font-size: 16px; letter-spacing: -0.01em;"
>{WEBSITE_NAME}</text>
</svg>`;
}
  • The getTitleLines function handles text wrapping for the title, ensuring that:
    • Words aren't cut off mid-word
    • Lines don't exceed the maximum length
    • The text remains readable and properly formatted
  • The getDescriptionLines function does the same for the description.
  • The generateBlogHeaderSVG function takes the post data and generates the SVG template with the title, description, and date.

Generating the OG Image with an API Route

Now, we can use the generateBlogHeaderSVG function to generate the SVG image for the blog post:

apps/web/app/(marketing)/blog/[slug]/og/route.ts
import { NextRequest } from 'next/server';
import path from 'node:path';
import { createCmsClient } from '@kit/cms';
const basePath = path.resolve(process.cwd());
const fontsPath = path.resolve(basePath, 'fonts');
const fontConfigPath = path.resolve(fontsPath, 'fonts.conf');
path.resolve(fontsPath, 'Inter.ttf');
export const dynamic = 'force-dynamic';
export async function GET(
_: NextRequest,
{
params,
}: {
params: Promise<{
slug: string;
}>;
},
) {
const cms = await createCmsClient();
const slug = (await params).slug;
const item = await cms.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!item) {
return new Response('Not found', { status: 404 });
}
const date = new Date(item.publishedAt);
const dateString = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
process.env.FONTCONFIG_FILE = fontConfigPath;
process.env.FONTCONFIG_PATH = fontsPath;
const svg = generateBlogHeaderSVG({
date: dateString,
title: item.title,
description: item.description ?? '',
});
try {
const { default: sharp } = await import('sharp');
const image = await sharp(Buffer.from(svg)).webp().toBuffer();
// Cache for a year
const cacheTime = 60 * 60 * 24 * 365;
const cacheControl = `public, no-transform, max-age=${cacheTime}, immutable`;
return new Response(image, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': cacheControl,
},
});
} catch (e) {
console.error(e);
return new Response(null, {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
});
}
}

The SVG Template

The SVG template creates a professional-looking card layout with:

  • Gradient background
  • Custom fonts
  • Nicely formatted text with proper line breaks
  • Consistent spacing and typography

Benefits

This approach gives us several advantages:

  • Dynamic - Images are generated on-demand with latest content
  • Fast - Sharp is extremely performant for image processing
  • Cached - Images are cached for a year after generation
  • Flexible - Easy to update design by modifying the SVG template
  • Lightweight - No heavy external dependencies needed

Add the Dynamic OG Image to Your Makerkit Project

To add this to your own Next.js project:

  1. Install dependencies in the "apps/web" folder
pnpm add --filter web sharp
  1. Create the API route handler
  2. Add the SVG template generation
  3. Configure your pages to use the dynamic OG URLs

Testing Your OG Images

To test your OG images:

  1. Visit your route directly (e.g., /blog/your-post/og)
  2. Use social media debugging tools:

Loading Fonts in Vercel

When using Vercel or AWS Lambda, the fonts may not be available - and Sharp won't be able to render the font correctly.

To fix this, let's make sure the fonts are available in your project:

  1. Create a fonts folder in the root of your project
  2. Place the font files in the fonts folder
  3. Add the following to your next.config.js file:
  4. Add a fonts.conf in the fonts folder:
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<dir>/var/task/apps/web/fonts/</dir>
<cachedir>/tmp/fonts-cache/</cachedir>
<config></config>
</fontconfig>

If you want to use a different font, you can replace the Inter font with your own font.

Common Issues

  1. Font Loading Issues: Make sure your font paths are correct and the fonts are available in your project
  2. Image Caching: If you're not seeing updates, you might need to clear your browser cache
  3. Sharp Installation: On some systems, you might need to install additional dependencies for Sharp to work correctly

Conclusion

Dynamic OG images are a great way to make your content more engaging on social media. With Next.js and Sharp, implementing them is straightforward and gives you full control over the design.

Let me know if you have any questions about implementing this in your own projects!

Happy coding! 🚀