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
andtwitter-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.