Open Graph images using the Next.js App Router

Introduction

Stick around if you want to create dynamic Open Graph images like this using the Next.js App Router. This is part one of two: in this part the images we generate will be pretty simple, but you can skip ahead to part two to create more engaging content as shown below if you want.

One of the quieter features the new Next.js App Router brings with it is an improvement to the process of adding Open Graph images to your site. In fact, the Metadata story as a whole is much improved with a far more streamlined developer experience. But that story is for another day: today we're going to concentrate specifically on generating Open Graph images. Before we begin, set up a fresh Next.js project if you want to code along, or feel free to skip ahead and check out the finished code on GitHub (there's not much to it).

npx create-next-app@latest og-image-demo

✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes

I've just hit enter on all the default options, leading to an App Router project with no src/ directory written in TypeScript. At the time of writing this pulled in next@13.4.19, but your version may differ slightly.

Using static images

Adding a file named opengraph-image.png (or .jpg, .jpeg, .gif) to any route segment will cause Next.js to automatically add a bunch of <meta> tags to the <head> section of each page within that segment, serving the image up as an Open Graph image. That might not sound all that impressive until you see what it actually amounts to. First of all, let's dump the contents of the <head> tag of our brand-new project:

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="preload" as="font" href="/_next/static/media/2aaf0723e720e8b9-s.p.woff2" crossorigin="" type="font/woff2">
  <link rel="preload" as="image" href="/vercel.svg" fetchpriority="high">
  <link rel="preload" as="image" href="/next.svg" fetchpriority="high">
  <link rel="stylesheet" href="/_next/static/css/app/layout.css?v=1694701657545" data-precedence="next_static/css/app/layout.css">
  <link rel="preload" href="/_next/static/chunks/webpack.js?v=1694701657545" as="script" fetchpriority="low">
  <script src="/_next/static/chunks/main-app.js?v=1694701657545" async=""></script>
  <title>Create Next App</title>
  <meta name="description" content="Generated by create next app">
  <link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16">
  <meta name="next-size-adjust">
  <script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
</head>

Now, let's add a dummy image to the app/ directory alongside layout.tsx and page.tsx and take another look at the contents of <head> on our home page:

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="preload" as="font" href="/_next/static/media/2aaf0723e720e8b9-s.p.woff2" crossorigin="" type="font/woff2">
  <link rel="preload" as="image" href="/vercel.svg" fetchpriority="high">
  <link rel="preload" as="image" href="/next.svg" fetchpriority="high">
  <link rel="stylesheet" href="/_next/static/css/app/layout.css?v=1694702590699" data-precedence="next_static/css/app/layout.css">
  <link rel="preload" href="/_next/static/chunks/webpack.js?v=1694702590699" as="script" fetchpriority="low">
  <script src="/_next/static/chunks/main-app.js?v=1694702590699" async=""></script>
  <title>Create Next App</title>
  <meta name="description" content="Generated by create next app">
  <meta property="og:image:type" content="image/png">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta property="og:image" content="http://localhost:3004/opengraph-image.png?d6481c98bee4241b">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:image:type" content="image/png">
  <meta name="twitter:image:width" content="1200">
  <meta name="twitter:image:height" content="630">
  <meta name="twitter:image" content="http://localhost:3004/opengraph-image.png?d6481c98bee4241b">
  <link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16">
  <meta name="next-size-adjust">
  <script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
</head>

Just look at all those juicy new <meta> tags! Elon will be delighted. It's worth taking a moment to think about what you haven't done to get this working:

  • You haven't had to write a single line of code
  • You haven't needed to worry about whether your image is publicly accessible or web-optimized
  • You haven't specified any of the additional metadata such as the image type or dimensions
  • You didn't need to install @vercel/og or wire up your own API handler

Next.js isn't simply serving up your image verbatim either: it's statically optimizing it; generating and caching it at build time, not request time.

I'd say all of that is a pretty incredible trade up for one image!

Using different images on different pages

As I've already mentioned, each opengraph-image.png will be automatically applied to every page within a segment. Since we've stuck ours in the root of our app/ directory (alongside the root layout.tsx file), it will be applied to all routes served by the application, though can be overridden at any level. Think of it like this: wherever you can define a page.tsx or a layout.tsx, you can add an opengraph-image.png.

Dynamic images

Static images will cover some use cases but inevitably at some point you'll bump into their limitations, especially if you're using any form of dynamic routing. As you might expect, Next.js has you covered here too. We're going to start by generating a dynamic image based on the current day of the week: not particularly useful, but a good way to dip our toes in the water. Create a new folder inside app/ called hello-today/ and create a skeleton page.tsx file:

// app/hello-today/page.tsx
export default function Hello() {
  return <h1>Hello Today!</h1>
}

We're not really interested in the actual page itself, but we need a page to attach some metadata to. Navigate to http://localhost:3000/hello-today and you should see a fairly underwhelming Hello Today! message and not a lot else. If you care to do so, you can inspect the <head> of the page and you'll see that it's still got all the same <meta> tags as before and is still pulling in our static Open Graph image. That's expected, because our static image lives in the root of the app/ directory and is therefore applied to every page unless overridden. Let's do that.

Create a new opengraph-image.tsx file in the hello-today/ directory and add to it the following code. Note the extension here; the file naming convention is the same, but the .tsx extension hints that our file will contain code, not image data:

// app/hello-today/opengraph-image.tsx
import { ImageResponse } from "next/server";

export const size = {
  width: 1200,
  height: 630,
};

const daysOfWeek = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
];

export default function Image() {
  const today = new Date();
  const dayName = daysOfWeek[today.getDay()];
  return new ImageResponse(<div>Happy {dayName}!</div>, { ...size });
}

Now take another look at the <head> of the page:

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="preload" as="font" href="/_next/static/media/2aaf0723e720e8b9-s.p.woff2" crossorigin="" type="font/woff2">
  <link rel="stylesheet" href="/_next/static/css/app/layout.css?v=1694705322241" data-precedence="next_static/css/app/layout.css">
  <link rel="preload" href="/_next/static/chunks/webpack.js?v=1694705226255" as="script" fetchpriority="low">
  <script src="/_next/static/chunks/main-app.js?v=1694705226255" async=""></script>
  <title>Create Next App</title>
  <meta name="description" content="Generated by create next app">
  <meta property="og:image:type" content="image/png">
  <meta property="og:image" content="http://localhost:3004/hello-today/opengraph-image?225888f3dbf80e21">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:image" content="http://localhost:3004/hello-today/opengraph-image?225888f3dbf80e21">
  <script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
  <meta name="twitter:image:type" content="image/png">
  <meta name="twitter:image:width" content="1200">
  <meta name="twitter:image:height" content="630">
  <link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16">
  <meta name="next-size-adjust">
  <link rel="preload" as="style" href="/_next/static/css/app/layout.css?v=1694705322241">
</head>

Clearly, the og:image and twitter:image tags now point at our new dynamic image. Let's pop open the URL they're pointing to and see what's what:

Awesome! Very basic and a little contrived, but a good start. Before we go any further, let's review some of the TypeScript code we just wrote, since the last line in particular deserves some explanation:

export const size = {
  width: 1200,
  height: 630,
};

...

return new ImageResponse(<div>Happy {dayName}!</div>, { ...size });

If you've used Vercel's @vercel/og package in the past this might look familiar to you, and for good reason: under the hood, it is @vercel/og - which is now bundled with the Next.js App Router distribution. The first parameter is of type ReactElement, which explains the familiar (albeit at first glance out-of-place) JSX syntax. The second parameter supports a range of options; here we're using the spread operator to pass in the size object we defined at the top of the page which instructs ImageResponse to create an image of 1200x630 pixels. You can leave these off since they're the default values assumed by @vercel/og, but you do need the export const size = {...} line if you want Next.js to add the og:image:width and og:image:height tags to your page. I would recommend leaving both of them in.

Markup and CSS in images

Now that we know the first argument passed to ImageResponse is a ReactElement, can we let loose and chuck any old JSX in there? Not quite. Only a limited subset of HTML elements are supported and the CSS implementation is incomplete. If you want to get your hands dirty, the library responsible for rendering your HTML as an image is Vercel's Satori, but for now we're just going to create a slightly less basic image to illustrate the point before we move on. Short on inspiration, we're going to pinch some styling from one of the official Next.js examples:

// app/hello-today/opengraph-image.tsx
import { ImageResponse } from "next/server";

export const size = {
  width: 1200,
  height: 630,
};

const daysOfWeek = [
  ...
];

export default function Image() {
  const today = new Date();
  const dayName = daysOfWeek[today.getDay()];
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 128,
          background: "white",
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        Happy {dayName}!
      </div>
    ),
    { ...size }
  );
}

It's not quite the Mona Lisa, but it'll do. Check out Vercel's OG Image Examples to see exactly what you can and can't do, even including experimental support for Tailwind CSS!

Dynamic images with dynamic routes

Where dynamic image generation really starts to make sense is in tandem with dynamic routes. If you're following along with the example, create a new directory called [name] underneath the app/hello-today directory we created earlier (the square brackets are Next.js's long-established naming convention for declaring a dynamic route segment). Inside that directory, create another page.tsx and opengraph-image.tsx file as before, but this time with the following contents:

// app/hello-today/[name]/page.tsx
export default function HelloName({ params }: { params: { name: string } }) {
  return <h1>Hello {params.name}!</h1>;
}

As before, we're not really interested in the page contents here, but let's make it dynamic based on the route anyway. What we're really interested in is using that dynamic parameter in our image generation logic:

// app/hello-today/[name]/opengraph-image.tsx
import { ImageResponse } from "next/server";

export const size = {
  width: 1200,
  height: 630,
};

const daysOfWeek = [
  ...
];

export default function Image({ params }: { params: { name: string } }) {
  const today = new Date();
  const dayName = daysOfWeek[today.getDay()];

  // uppercase the first letter of the slug, lowercase the rest of it:
  const name =
    params.name.charAt(0).toUpperCase() + params.name.slice(1).toLowerCase();
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 96,
          background: "black",
          color: "white",
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        Happy {dayName}, {name}!
      </div>
    ),
    { ...size }
  );
}

We've really pushed the boat out this time and even come up with our own dazzling dark theme. Behold the fruits of our labour:

And voila! A dynamic social Open Graph image which changes on the route and the current day of the week. But we're not quite done yet.

Image caching

Lurking in that innocuous new Date() call is some implicit state which isn't derived from the request itself. How does Next.js know when today is no longer... today? The answer is that of course it doesn't, and if we're not careful we're going to be bitten by some fairly aggressive cache policies. Let's push this code up to Vercel and run some tests against the dynamically-generated Open Graph image:

time curl -I "https://og-image-demo-qlip2e8dr-nick26.vercel.app/hello-today/nick/opengraph-image?ee7f5fcb246fd960"
HTTP/2 200
age: 0
cache-control: public, immutable, no-transform, max-age=31536000
content-type: image/png
date: Fri, 15 Sep 2023 05:28:53 GMT
server: Vercel
strict-transport-security: max-age=63072000; includeSubDomains; preload
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
x-matched-path: /hello-today/[name]/opengraph-image
x-robots-tag: noindex
x-vercel-cache: MISS
x-vercel-execution-region: iad1
x-vercel-id: lhr1::iad1::r4p5n-1694755731949-c1c8bce50b36
content-length: 0


________________________________________________________
Executed in    1.95 secs      fish           external
   usr time   18.17 millis    0.10 millis   18.06 millis
   sys time   14.55 millis    1.56 millis   12.99 millis

All as we might expect: a cache miss on the Vercel side leading to a rather slow response time (exaggerated in my case by the transatlantic hop to iad1, hosted in North America), but a very cacheable resource in return. Let's try again:

time curl -I "https://og-image-demo-qlip2e8dr-nick26.vercel.app/hello-today/nick/opengraph-image?ee7f5fcb246fd960"
HTTP/2 200
age: 34
cache-control: public, immutable, no-transform, max-age=31536000
content-type: image/png
date: Fri, 15 Sep 2023 05:28:53 GMT
server: Vercel
strict-transport-security: max-age=63072000; includeSubDomains; preload
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
x-matched-path: /hello-today/[name]/opengraph-image
x-robots-tag: noindex
x-vercel-cache: HIT
x-vercel-execution-region: iad1
x-vercel-id: lhr1::iad1::dbsvs-1694755768393-7cf37146944b
content-length: 0


________________________________________________________
Executed in  107.05 millis    fish           external
   usr time   11.94 millis   57.00 micros   11.88 millis
   sys time   13.00 millis  853.00 micros   12.15 millis

Again much as we might expect: a much faster response and a cache hit. But the problem we have now is that this image will NOT be refetched until it is stale, which with a max-age of 31536000 seconds - one year - means our greeting is going to age very badly indeed.

Cache busting

It might not surprise you to see the cache-control values in the responses above, but according to my incomplete understanding of the App Router world, they actually should surprise you. The image exists under a dynamic route, which according to this handy table should cause the route to always be dynamically rendered. Indeed, requesting the page itself rather than the Open Graph image seems to exhibit this behaviour:

curl -I "https://og-image-demo-eight.vercel.app/hello-today/nick" | grep cache                                         main

cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS

Similarly, the official Next.js documentation indicates that dynamic Open Graph images support the usual array of App Router cache directives, indicating that they should behave like any other route segment:

opengraph-image and twitter-image are specialized Route Handlers that can use the same route segment configuration options as Pages and Layouts. Source

So, what gives?

After a fair amount of frustration, I realised that these directives conflict with the @vercel/og image library we're quietly using under the hood, which states:

Vercel OG automatically adds the correct Cache-Control headers to ensure the image is cached at the Edge after it’s been generated. Source

Indeed, try as I might to convince Vercel not to cache these images, I simply could not until discovering that the @vercel/og library had been trumping the App Router cache directives I was trying all along. The solution I've put in place isn't particularly pretty and one I'd be very cautious about adopting for anything other than demo purposes; I've resorted to manually specifying the outgoing Cache-Control header in the ImageResponse:

// app/hello-today/[name]/opengraph-image.tsx
import { ImageResponse } from "next/server";

// switching to the edge runtime is important: since we're not going to cache anything anymore, we
// need to minimise cold starts and serve the image from the closest location to the request origin
export const runtime = "edge";

export const size = {
  width: 1200,
  height: 630,
};

const daysOfWeek = [
  ...
];

export default function Image({ params }: { params: { name: string } }) {
  const today = new Date();
  const dayName = daysOfWeek[today.getDay()];

  // uppercase the first letter of the slug, lowercase the rest of it:
  const name =
    params.name.charAt(0).toUpperCase() + params.name.slice(1).toLowerCase();
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 96,
          background: "black",
          color: "white",
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        Happy {dayName}, {name}!
      </div>
    ),
    {
      ...size,
      // this is definitely down and dirty, but gets the job done for demo purposes.
      // I wouldn't recommend these settings for production!
      headers: {
        "Cache-Control":
          "private, no-cache, no-store, max-age=0, must-revalidate",
      },
    }
  );
}

I can't say this solution feels great, but I'm yet to find a better one - if you've got one, please let me know! It's probably a far better plan to make your images cacheable: don't use any implicit state and don't fight the system. Note also the switch to using the edge runtime, which is important since we're no longer caching anything so need to mitigate the impact of Serverless cold starts.

Working around rendering limitations

One way to circumvent the HTML and CSS rendering limitations when using ImageResponse is to keep the markup simple and return an embedded <img> tag which in turn fetches a dynamic image from somewhere else. Vercel have some examples of doing this. You might be wondering why you'd bother wrapping a source image in another image when you could fetch it directly by declaring a Metadata object on each page and setting its openGraph.image to the URL of your dynamic image. That would work too, but creating a handler lets you add some extra decoration to your image with HTML and CSS which you might want apply when serving it as an Open Graph image. You can see some code which does exactly that in part two of this series: Embedding Screenshots in Next.js Open Graph Images, which covers how the dynamic Open Graph images for shipshape.dev are generated: when someone shares a dashboard on social media, they get a snapshot of the dashboard and its data at the time they shared it (in theory at least - in practice, I've still got a few bugs to squash). Overkill? Yes. Cool? I think so. If you're curious, take a look at that article.

Wrap up and next steps

We've covered the basics of the much-impoved Open Graph image support introduced by the Next.js App Router, but if you want to go further and learn how to embed images within your Open Graph images, check out part two of this series: Embedding Screenshots in Next.js Open Graph Images.

If you enjoyed this article, please consider sharing it on X or your platform of choice - it helps a lot. I’m all ears for any feedback, corrections or errors, so please if you have any. Thanks!