Opengraph dynamic social images

Zachary Proser's default opengraph image

What is opengraph?

Opengraph is a standard for social media image formats. It's the "card" that is rendered whenever you or someone else shares a URL to your site on social media:

Opengraph images and markdown determine what social media cards are shown

It's considered a good idea to have an opengraph image associated with each of your posts because it's a bit of nice eye candy that theoretically helps improve your click-through rate. A high quality opengraph image can help make your site look more professional.

Implementing my desired functionality

This took me a bit to wrap my head around. The examples Vercel provides were helpful and high quality as usual, (they even have a helpful opengraph playground) but I wish there had been more of them. It took me a while to figure out how to implement the exact workflow I wanted:

  1. Hero Image Integration: Each post has a "hero" image displayed on the blog index page. I wanted the Open Graph image for a post to feature both the post's title and its associated hero image.
  2. Dynamic Generation: The image generation happens on-the-fly using @vercel/og.
  3. Caching: To improve performance and reduce generation costs, successfully generated images are cached as static files. Subsequent requests for the same image serve the static file directly.
  4. Fallback Image: A default fallback image is used for pages without a specific hero image (like the home page or index pages) or if an error occurs during generation.

This setup ensures an attractive, relevant Open Graph image for shared posts, a consistent fallback for other pages, and efficient delivery through caching.

If you look closely at the default image (right click the image below and open it in a new tab), you can see that my image has two linear gradients, one for the green background which transitions between greens from top to bottom, and one for blue which transitions left to right.

In addition, each band has a semi-transparent background image - giving a brushed aluminum effect to the top and bottom green bands and a striped paper effect to the center blue card where the title and hero image are rendered. I was able to pull this off due to the fact that Vercel's @vercel/og package allows you to use Tailwind CSS in combination with inline styles.

The API Routes: Caching and Generation

My implementation uses two API routes:

  1. /api/og: This is the primary endpoint used in <meta> tags.
    • It first checks if a pre-generated, cached static image exists for the requested slug (e.g., /api/og?slug=/blog/my-cool-post).
    • If a cached image is found, it's served immediately (usually via Vercel's Edge Network).
    • If no cached image exists, it redirects the request to the /api/og/generate endpoint, passing along the original parameters.
  2. /api/og/generate: This endpoint handles the actual image generation.
    • It looks up the hero image associated with the provided slug.
    • If no slug is provided or no hero image is found for that slug, it uses the default fallback background.
    • It takes title and description query parameters to render the text onto the image.
    • It generates the image using @vercel/og and serves it directly. It also triggers a background process to save this newly generated image to the static cache for future requests to /api/og.

Fallback Image Example

This is my fallback image, rendered by hitting /api/og without a slug parameter. Its src parameter is ${process.env.NEXT_PUBLIC_SITE_URL}/api/og, which computes to "$https://zackproser.com/api/og".

Zachary Proser's default topengraph image

Dynamically Rendered Examples

Here's how the URL looks for a specific blog post. The /api/og endpoint uses the slug to find the cached image or trigger generation.

Blog post with dynamic title and hero image looked up via slug:

src={`${process.env.NEXT_PUBLIC_SITE_URL}/api/og?slug=/blog/retrieval-augmented-generation`}
Retrieval Augmented Generation post

Another blog post example:

src={`${process.env.NEXT_PUBLIC_SITE_URL}/api/og?slug=/blog/javascript-ai`}
AI-powered and built with JavaScript post

Direct Generation (Less Common):

While the primary method is using /api/og?slug=..., you can hit the /api/og/generate endpoint directly if needed, perhaps for testing. It requires title and description. If you omit a specific imageUrl param, it will attempt the hero image lookup based on slug if provided, or use the default background.

// Example generating directly with title/description, letting it use default background
src={`${process.env.NEXT_PUBLIC_SITE_URL}/api/og/generate?title=This is a Test Title&description=Description goes here`}

// Example forcing a specific background image URL
src={`${process.env.NEXT_PUBLIC_SITE_URL}/api/og/generate?title=Test Title&description=Desc&imageUrl=/_next/static/media/some-image.webp`}

Dynamically generated title with default background

Understanding the Flow

The core idea is that your Next.js site provides metadata in the <head> of each page. For Open Graph images, this looks like:

<meta property="og:image" content="https://your-site.com/api/og?slug=/your-page-slug" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

When a social media crawler (or a browser) sees this tag, it makes a request to the specified URL (/api/og?slug=...).

  1. Request hits /api/og: The server checks its static cache (e.g., a designated directory like /public/og-cache/) for an image corresponding to /your-page-slug.
  2. Cache Hit: If your-page-slug.png (or similar) exists in the cache, it's served directly and quickly.
  3. Cache Miss: If the image isn't cached, /api/og issues a redirect (HTTP 307 or 308) to /api/og/generate?slug=/your-page-slug (preserving any other relevant parameters like title/description if they were initially passed, though slug is primary).
  4. Request hits /api/og/generate:
    • The generation route fetches necessary data (post title, description, hero image path based on the slug from a data source like a CMS or local files).
    • It constructs the image content using @vercel/og, rendering the fetched text and hero image (or fallback background).
    • It returns the generated image (ImageResponse) to the crawler/browser.
    • (Asynchronously) It saves the generated image to the static cache directory (/public/og-cache/your-page-slug.png) so the next request to /api/og?slug=/your-page-slug results in a cache hit.

This combination of on-demand generation and static caching provides both flexibility and performance.

The actual implementation details of the API routes can be found in the site's source code.

This entire site is open-source and available at github.com/zackproser/portfolio

Here's my ArticleLayout.jsx component, which forms the <meta name="og:image" content={ogURL} /> in the head of each post to provide the URL that social media sites will call when rendering their cards:

import Head from 'next/head'
import { useRouter } from 'next/router'

import { Container } from '@/components/Container'
import { Newsletter } from '@/components/Newsletter'
import FollowButtons from '@/components/FollowButtons'
import { Prose } from '@/components/Prose'
import { formatDate } from '@/lib/formatDate'

function ArrowLeftIcon(props) {
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
      <path
        d="M7.25 11.25 3.75 8m0 0 3.5-3.25M3.75 8h8.5"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  )
}

export function ArticleLayout({
  children,
  metadata,
  isRssFeed = false,
  previousPathname,
}) {
  let router = useRouter()

  if (isRssFeed) {
    return children
  }

  const sanitizedTitle = encodeURIComponent(metadata.title.replace(/'/g, ''));

  // opengraph URL that gets rendered into the HTML, but is really a URL to call our backend opengraph dynamic image generating API endpoint
  let ogURL = `${process.env.NEXT_PUBLIC_SITE_URL}/api/og?title=${sanitizedTitle}`
  
  // Add slug parameter from metadata to ensure static image lookup works
  if (metadata.slug) {
    // Extract the last part of the slug (e.g., "opengraph-integration" from "blog/opengraph-integration")
    const slugParts = metadata.slug.split('/');
    const lastSlugPart = slugParts[slugParts.length - 1];
    ogURL += `&slug=${lastSlugPart}`;
  }

  // If the post includes an image, append it as a query param to the final opengraph endpoint
  if (metadata.image && metadata.image.src) {
    ogURL = ogURL + `&image=${metadata.image.src}`
  }

  console.log(`ArticleLayout ogURL: ${ogURL}`);

  let root = '/blog/'
  if (metadata?.type == 'video') {
    root = '/videos/'
  }

  const builtURL = `${process.env.NEXT_PUBLIC_SITE_URL}${root}${metadata.slug ?? null}`
  const postURL = new URL(builtURL)

  return (
    <>
      <Head>
        <title>{`${metadata.title} - Zachary Proser`}</title>
        <meta name="description" content={metadata.description} />
        <meta name="og:image" content={ogURL} />
        <title>{metadata.title}</title>
        <meta property="og:title" content={metadata.title} />
        <meta name="description" content={metadata.description} />
        <meta name="og:image" content={ogURL} />
        <meta name="og:url" content={postURL} />
        <meta property="og:type" content="website" />

        <meta name="twitter:card" content="summary_large_image" />
        <meta property="twitter:domain" content="zackproser.com" />
        <meta property="twitter:url" content={postURL} />
        <meta name="twitter:title" content={metadata.title} />
        <meta name="twitter:description" content={metadata.description} />
        <meta name="twitter:image" content={ogURL} />

      </Head>
      <Container className="mt-16 lg:mt-32">
        <div className="xl:relative">
          <div className="mx-auto max-w-2xl">
            {previousPathname && (
              <button
                type="button"
                onClick={() => router.back()}
                aria-label="Go back to articles"
                className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:-mt-2 lg:mb-0 xl:-top-1.5 xl:left-0 xl:mt-0"
              >
                <ArrowLeftIcon className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400" />
              </button>
            )}
            <article>
              <header className="flex flex-col">
                <h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
                  {metadata.title}
                </h1>
                <time
                  dateTime={metadata.date}
                  className="order-first flex items-center text-base text-zinc-400 dark:text-zinc-500"
                >
                  <span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
                  <span className="ml-3">{formatDate(metadata.date)}</span>
                </time>
              </header>
              <Prose className="mt-8">{children}</Prose>
            </article>
            <Newsletter />
            <FollowButtons />
          </div>
        </div>
      </Container>
    </>
  )
}

Thanks for reading

If you enjoyed this post or found it helpful in anyway, do me a favor and share the URL somewhere on social media so that you can see my opengraph image in action 🙌😁.