Adding opengraph image with output: static

If you've been following this website for a while (or, most likely, I've sent you this blog and opengraph image before), you might've seen that I've added opengraph images. For example, this is the opengraph image for my previous post.

An opengraph image from my previous post

An opengraph image from my previous post

As you might've guessed, I need to generate this image for each post. This is a trivial task, right? Well, kinda. It is really easy to do if you don't export your site but instead run it on, say, vercel. But if you do, things get strange really, really quick.

Googling

Well, as a good developer you firstly google your issue. You find several answers, including one on the nextjs page itself. Task accomplished, right? No. A big No.
You see, this post assumes you host your site, not statically export it. There was a github issue regarding this, but it was unanswered. After that I've found a reddit post with quite not the thing i needed, but the source of this package from the post got me an idea.
My idea was to generate images using an api route and then screenshot them with playwright. The same idea is used in Maxime Heckel's blog, which would probably work, but i use NixOS, a declarative linux distro. The Playwright package wasn't working correctly. Not only this, but taking screenshots of pages meant that it would slow down the build process drastically. So, i did more googling. And found a github issue. This was exactly what i needed

What the issue suggests

The issue mentions that you can do [...slug]/og.png route with <ImageResponse>. Surprisingly, this works. If not a one caveat... You CAN'T use tailwind className in it. Well, I surely won't style the image with usual css, so i found tw-to-css.

With this in mind, I implemented the OG image.

Implementation

So, you create the route as I've written above. My structure looks like posts/[...slug]/og-normal.png, as I plan to add another OG image for twitter soon. Then, It's just matter of styling. My code looks like this.

import { allPosts } from 'contentlayer/generated'
import { format, parseISO } from 'date-fns'
import { ImageResponse } from 'next/server'
import { twj } from 'tw-to-css'
import { Inter } from 'next/font/google'
import { notFound } from 'next/navigation'
 
export async function generateStaticParams() {
  return allPosts.map((post) => ({
    slug: post._raw.flattenedPath,
  }))
}
 
const inter = Inter({ subsets: ['latin'] })
 
const getInterExtraBold = async () => {
  return fetch(
    new URL('fonts/Inter-ExtraBold.ttf', 'https://banana.is-cool.dev/fonts'),
  ).then((res) => res.arrayBuffer())
}
 
const getInterBold = async () => {
  return fetch(
    new URL('fonts/Inter-Bold.ttf', 'https://banana.is-cool.dev/fonts'),
  ).then((res) => res.arrayBuffer())
}
 
export async function GET(
  req: Request,
  { params }: { params: { slug: string } },
) {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
 
  if (!post) {
    return notFound()
  }
 
  return new ImageResponse(
    (
      <div
        style={twj('h-full w-full flex bg-zinc-950 items-center justify-center flex-col')}
 
      >
        <div tw='flex flex-col'>
          <h1
            tw='text-zinc-100 font-extrabold text-6xl font-sans max-w-2xl'>
            {post.title}
          </h1>
          <p tw='text-zinc-500 text-2xl max-w-2xl mt-[-10] mb-5'>
            {post.description}
          </p>
          <div style={twj('flex items-start')}>
            <time dateTime={post.date}>
              <div style={twj('flex flex-row items-center mr-6')}>
                <svg
                  style={twj('text-zinc-600 mb-[5px] mr-2')}
                  xmlns="http://www.w3.org/2000/svg"
                  width="20"
                  height="20"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  stroke-width="2"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                >
                  <path d="M8 2v4" />
                  <path d="M16 2v4" />
                  <rect width="18" height="18" x="3" y="4" rx="2" />
                  <path d="M3 10h18" />
                </svg>{' '}
                <p tw='text-zinc-600 text-2xl'>
                  {format(parseISO(post.date), 'LLLL d, yyyy')}
                </p>
              </div>
            </time>
            <div tw='flex flex-row items-center'>
              <svg
                style={twj('text-zinc-600 mb-[4px] mr-2')}
                xmlns="http://www.w3.org/2000/svg"
                width="20"
                height="20"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
              >
                <circle cx="12" cy="12" r="10" />
                <polyline points="12 6 12 12 16 14" />
              </svg>{' '}
              <p tw='text-zinc-600 text-2xl'>
                {post.readingTime} min read
              </p>
            </div>
          </div>
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Inter ExtraBold',
          data: await getInterExtraBold(),
        },
        {
          name: 'Inter Bold',
          data: await getInterBold(),
        },
      ],
    },
  )
}

Caveats

Some important limitations to consider are:

  • You can't use your usual font (or next/font for that matter), you have to host it yourself. I just put it in /public
  • You can't use className
  • Debug is not easy
  • All classes that do calculations (i.e. space-x, space-y) won't work.
  • Lucide icons won't show up, so you should use SVGs

Share it!

As you can see, the Implementation is rather simple. The hardest part is that the information is hard to find online, so it would be nice if you've shared it with your developer friends.

A short off-topic description of what I've added

Changelog

Those posts serve a purpose of being kind-of a devlog for now, so, I thought I'd list changes done

  • As you can already imagine, opengraph image.
  • I filled main page with actual info
  • I've added a posts page with search functionality
  • Latest posts now show 3 latest posts, but I might show pinned ones aswell
  • I'm about to (hopefully) start indexing this site in google

Search explained

For search I'm using Fuse. It's super simple to setup search. It's just

const [input, setInput] = useState<any>(posts);
  const handleSearch = (event: any) => {
    const { value } = event.target;
    if (value.length === 0) {
      setInput(posts);
      return;
    }
 
    const fuse = new Fuse(posts, {
      keys: ["title", "description"],
    });
 
    const results = fuse.search(value);
    const items = results.map((result) => result.item);
    setInput(items);
  };

And, in the input field

<Input placeholder='Search posts...' onChange={handleSearch} />

Then you just display your items iterating through input variable. It's that simple. Note that the search will be client-side.