You know those eye-catching preview images that show up when you share a link? You want that for your Next.js site, but creating custom images for every single page sounds like a nightmare. Plus, you've already spent hours trying to get custom fonts to work consistently across environments, only to be met with frustration.
And don't even get me started on the custom font issues - you've scoured forums and tried every trick in the book, but those fancy typefaces just won't cooperate once your site is deployed.
With this tutorial, you could create a reusable OpenGraph image generator that renders any text you want in a beautiful, branded template. This generator would work seamlessly with custom fonts and prevent unauthorized usage by verifying requests with a secure HMAC signature.
You will leverage Vercel's Image Generation capabilities, which uses Sartori under the hood, to build an API endpoint that programmatically creates these social media preview images using your own custom designs and assets. Best of all, you can integrate it directly into your app, generating unique images for each page's metadata with just a single line of code.
Step 1: Set Up the Image Template
First, you'll need a reusable React component that renders your OpenGraph image template. This could be as simple or complex as you'd like - from basic text on a solid background to multi-layered designs with graphics and patterns.
Here's an example of a basic template that renders a title, label, and brand name:
// in `src/app/api/og-image/template.tsx`
import { OpenGraphImageParams } from "@/lib/og-image";
import { ImageResponse } from "next/og";
import type { FontMap } from "./fonts";
export const generateBannerImage = (
{ title, label, brand }: OpenGraphImageParams,
fonts: FontMap
) => {
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#fcf6f1",
justifyContent: "space-between",
fontFamily: "Inter-SemiBold",
color: "#212121",
padding: "40px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
}}
>
{label && (
<div
style={{
marginRight: "auto",
marginBottom: "40px",
color: "#fcf6f1",
background: "#060606",
padding: "5px 10px",
fontWeight: "600",
fontSize: "24px",
letterSpacing: "-0.05em",
}}
>
{label}
</div>
)}
<div
style={{
fontSize: "96px",
fontWeight: "900",
lineHeight: "6rem",
padding: "0 0 100px 0",
letterSpacing: "-0.025em",
color: "#212121",
fontFamily: "Inter-SemiBold",
lineClamp: 4,
}}
>
{title}
</div>
</div>
{brand && (
<div
style={{
position: "absolute",
right: "40px",
bottom: "40px",
fontSize: "32px",
fontWeight: "900",
}}
>
{brand}
</div>
)}
</div>
),
{
width: 1200,
height: 600,
fonts: [fonts["inter-semibold"], fonts["inter-regular"]],
}
);
};
This uses the next/og
library to generate an image dynamically, specifying the dimensions and any custom fonts we want to use.
Step 2: Load and Cache Custom Fonts
To ensure your custom fonts work flawlessly in any environment, you'll need to load (and cache them for performance) before rendering the image template.
This is how you can load the fonts from the public
folder:
// in `src/app/api/og-image/fonts.tsx`
import { config } from "../../../config";
export type FontMap = Record<
string,
{
data: Buffer | ArrayBuffer;
name: string;
weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
style?: "normal" | "italic";
lang?: string;
}
>;
let loadedFonts: FontMap | null = null;
const loadFontsRaw = async (): Promise<FontMap> => {
return {
"inter-semibold": {
name: "Inter",
data: await fetch(new URL("fonts/Inter-SemiBold.ttf", config.baseUrl)).then(
(res) => res.arrayBuffer()
),
weight: 600,
style: "normal",
},
"inter-regular": {
name: "Inter",
data: await fetch(new URL("fonts/Inter-Regular.ttf", config.baseUrl)).then(
(res) => res.arrayBuffer()
),
weight: 400,
style: "normal",
},
};
};
export const loadFonts = async (): Promise<FontMap> => {
if (loadedFonts) {
return loadedFonts;
}
loadedFonts = await loadFontsRaw();
return loadedFonts;
};
This code loads your custom font files from the /public/fonts/
directory and caches them in memory, ensuring they'll render correctly every time. Ensure that your baseUrl
points to the localhost url during development and the deployed site url when it's deployed.
Step 3: Create the OpenGraph Image API Route
Next, you'll set up a API route that generates the OpenGraph image on-demand. Adding this file to src/app/api/og-image/route.tsx
allows you to serve an dynamically generated OG image using the URL query parameters at the /api/og-image
endpoint.
// in `src/app/api/og-image/route.tsx`
import { verifyOgImageSignature } from "@/lib/og-image";
import type { NextRequest } from "next/server";
import { loadFonts } from "./fonts";
import { generateBannerImage } from "./template";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const title = searchParams.get("title");
if (!title) {
return new Response("Missing title", { status: 400 });
}
const label = searchParams.get("label") || undefined;
const brand = searchParams.get("brand") || undefined;
const fonts = await loadFonts();
return generateBannerImage({ title, label, brand }, fonts);
}
This route accepts title
, label
, and brand
query parameters, passes them to the ogImageTemplate
function, and returns the rendered image data as the response.
Try visiting http://localhost:3000/api/og-image/?title=Hello+World
to view a dynamically generated image!
Step 4: Secure the OpenGraph Image Endpoint
To prevent abuse and unauthorized usage of your OpenGraph image generator, you'll want to secure the API endpoint by verifying incoming requests using HMAC (Hash-based Message Authentication Code) signatures.
HMAC is a method for calculating a message authentication code using a cryptographic hash function and a secret key. By generating a signature for each valid request using a secret key only your application knows, you can verify that incoming requests are legitimate and haven't been tampered with.
First, you'll need to define a secret key to use for signing requests. This should be an environment variable that's kept secure and not checked into version control:
// src/config.ts
export const config = {
ogImageSecret: process.env.OG_IMAGE_SECRET || 'my-super-secret-key'
}
Next, create a utility function that generates HMAC signatures for a given set of OpenGraph image parameters as well as the functions to sign and verify the parameters and accompanying signature:
// in `src/lib/og-image.ts`
import { config } from "@/config";
import { createHmac } from "crypto";
import urlJoin from "url-join";
// Secret is used for signing and verifying the url to prevent misuse of your service to generate images for others
const secret = config.ogImageSecret;
export interface OpenGraphImageParams {
title: string;
label?: string;
brand?: string;
}
export const signOgImageParams = ({
title,
label,
brand,
}: OpenGraphImageParams) => {
const valueString = `${title}.${label}.${brand}`;
const signature = createHmac("sha256", secret)
.update(valueString)
.digest("hex");
return { valueString, signature };
};
export const verifyOgImageSignature = (
params: OpenGraphImageParams,
signature: string
) => {
const { signature: expectedSignature } = signOgImageParams(params);
return expectedSignature === signature;
};
export const signOgImageUrl = (param: OpenGraphImageParams) => {
const queryParams = new URLSearchParams();
queryParams.append("title", param.title);
if (param.label) {
queryParams.append("label", param.label);
}
if (param.brand) {
queryParams.append("brand", param.brand);
}
const { signature } = signOgImageParams(param);
queryParams.append("s", signature);
return urlJoin(config.baseUrl, `/api/og-image/?${queryParams.toString()}`);
};
This signOgImageParams
function takes the OpenGraph image parameters, concatenates them into a string, and generates an HMAC signature using the sha256
hashing algorithm and your secret key.
With this signature generation in place, you can update your API route to verify incoming requests before rendering images:
// in `src/app/api/og-image/route.tsx`
import { verifyOgImageSignature } from "@/lib/og-image";
import type { NextRequest } from "next/server";
import { loadFonts } from "./fonts";
import { generateBannerImage } from "./template";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const title = searchParams.get("title");
if (!title) {
return new Response("Missing title", { status: 400 });
}
const label = searchParams.get("label") || undefined;
const brand = searchParams.get("brand") || undefined;
const signature = searchParams.get("s") || "";
const verified = verifyOgImageSignature(
{
title,
label,
brand,
},
signature
);
if (!verified) {
return new Response("Invalid request", { status: 400 });
}
const fonts = await loadFonts();
return generateBannerImage({ title, label, brand }, fonts);
}
Step 5: Using Signed OpenGraph Image URLs
Finally, to make it easy to generate valid, signed URLs for your OpenGraph images, you can make use of the signOgImageUrl
helper function earlier.
This is how you might make use of generateMetadata
to return a dynamically generated OpenGraph image is there isn't a featured image after fetching blog posts using the wisp cms.
// src/app/blog/[slug]/page.tsx
export async function generateMetadata({
params: { slug },
}: {
params: Params;
}) {
const result = await wisp.getPost(slug);
if (!result || !result.post) {
return {
title: "Blog post not found",
};
}
const { title, description, image } = result.post;
const generatedOgImage = signOgImageUrl({ title, brand: config.blog.name });
return {
title,
description,
openGraph: {
title,
description,
images: image ? [image, generatedOgImage] : [generatedOgImage],
},
};
}
This is how you can use it in other pages that are not dynamically rendered, example is an about page for a personal blog:
// ...
export async function generateMetadata() {
return {
title: "About Me",
description: "Learn more about Samantha and her travel adventures",
openGraph: {
title: "About Me",
description: "Learn more about Samantha and her travel adventures",
images: [
signOgImageUrl({
title: "Samantha",
label: "About Me",
brand: config.blog.name,
}),
],
},
};
}
With this secure, HMAC-signed OpenGraph image generator in place, you can rest assured that your serverless functions won't be abused by unauthorized parties. Your content will look stunning when shared, driving more engagement and clicks, while your custom fonts render perfectly in any environment. Simply update the image template with your branding, and let the generator do the rest - it's that easy and secure! Share away, and watch your content shine.
To see this dynamic OpenGraph image generator in action, check out the Wisp Next.js Blog Starter - a full-featured blog template built with Next.js 14 and the new App Router. You can explore the specific commit that implements this OpenGraph image generation approach.
While you're there, be sure to check out Wisp - the headless CMS that powers this starter kit. Wisp makes it incredibly easy to add a powerful blog to your Next.js 14 website. The starter kit is a great way to get up and running quickly with a complete blogging solution for your Next.js app.