Building a Dynamic OpenGraph Image API Endpoint on Next.js

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.

Raymond Yeh

Raymond Yeh

Published on 15 May 2024

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS
Related Posts
Stunning Open Graph Image Generator Templates for Next.js

Stunning Open Graph Image Generator Templates for Next.js

Struggling to create visually appealing Open Graph images for social shares? Copy these proven Next.js 14 code templates to generate stunning, on-brand OG images with zero design skills required. Powered by the same generator used on wisp.blog!

Read Full Story
SEO Checklist for Next.js with App Router

SEO Checklist for Next.js with App Router

Boost your Next.js app's SEO with our guide! Learn to overcome technical challenges, use SSR/SSG, optimize images, and more to enhance search engine visibility.

Read Full Story
Static Site, Dynamic Content: Powering Your NextJS Static Site with Lightweight Blog-Only CMS

Static Site, Dynamic Content: Powering Your NextJS Static Site with Lightweight Blog-Only CMS

Tired of choosing between static site performance and dynamic content? Learn how to integrate Wisp CMS with NextJS for the ultimate blogging solution. Best of both worlds awaits!

Read Full Story