Dynamically generated open graph images using Astro and Urlbox

Often, it’s just not feasible for clients to manually create their own custom social share images for each page within their website. Sure, they could use the same image for all pages, but it’s actually quite nice to have these tailored specifically to the page or URL being shared.

It might seem like one small aspect in the grand scheme of building a website, but automating the generation of these social share images is a really nice touch, IMHO. Removing barriers to shipping content and cutting down on the number of assets that need to be created is always going to feel like a win.

There are several ways this can be achieved—whether that’s by using a package like Puppeteer, or a hosted screenshot API service like Screenshotlayer, among others, of course.

I’ve used Urlbox for this very purpose recently, and I really like the workflow. Sure, it is a paid service (and rightly so), so it may not be suitable for every use case, but it’s been an acceptable line item cost for the projects I’ve worked on.

Getting started

First, it’s worth browsing through the quick start guide on the Urlbox website. I often hate reading docs, but these are well written.

You’ll, of course, need to get set up with a Urlbox account too. Once signed up, you’ll get a Publishable Key and Secret Key from your Urlbox dashboard.

The method I’ve used with good success has been Urlbox’s Render Links feature, which lets you generate screenshots by simply rendering a given URL with custom parameters.

For the site build itself, I’m using Astro. It’s a framework I absolutely love working with. For other frameworks, the workflow will be somewhat similar, syntax aside.

Once we have all of this done, the // TODO list is roughly:

  1. Set up a [...slug].astro template that’s a catch-all dynamic route for all collections.
  2. Style this template accordingly and dynamically inject data such as the page title.
  3. Configure the <BaseLayout> template’s metadata to use Urlbox to render a screenshot for the requested page.

Easy as that, right?!

Setting up the template

I’ve found the easiest method is to create a folder and template using the following path structure: ./src/pages/og/[...slug].astro. I like to keep this separate to make it clear in the URL structure that these pages serve a specific purpose.

The following code is a pared-back example, but it shows the basic workings of my default template.

For now, I’m just pulling data from my writing collection, but this can easily be extended to include all content collections.

[...slug].astro

---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const writingPosts = await getCollection('writing');

  return writingPosts.map((entry) => ({
    params: { slug: entry.slug },
    props: { entry },
  }));
}

const { entry } = Astro.props;
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="robots" content="noindex" /><!-- We don't want these pages indexed -->
  </head>
  <body>
    <main>
      <h1>
        {entry.data.title}
      </h1>
    </main>
  </body>
</html>

It’s worth at this point showing the basic schema for the content collection itself.

config.ts

import { z, defineCollection } from 'astro:content';

const writingCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    pubDate: z.date(),
    excerpt: z.string().optional(),
  }),
});

export const collections = {
  writing: writingCollection,
};

Once this is done, you can visit any slug from your own content collection, in my example I go to http://localhost:4321/og/hello-world/ and see the following:

A preview of a social media share card displaying the title of the post with no styling.

Styling the template

For anything style-related I like to keep things simple. Firstly, I’ll grab my global CSS—the cascade should do a good job of informing a bunch of the design decisions here.

Next, I’ll add some styles specifically for the .social-card itself. I usually add these directly to the template itself, since it feels like the most logical place and we want to be intentional about their usage. They are only ever used in this context too.

[...slug].astro

---
import { getCollection } from 'astro:content';

import '../../css/global.css';

export async function getStaticPaths() {
  const allBlogPosts = await getCollection('writing');

  return allBlogPosts.map((entry) => ({
    params: { slug: entry.slug },
    props: { entry },
  }));
}

const { entry } = Astro.props;
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="robots" content="noindex" /><!-- We don't want these pages indexed -->
    <style>
      .social-card {
        display: flex;
        width: 1024px; /* Match the width of screenshot request via Urlbox */
        height: 526px; /* Match the height of screenshot request via Urlbox */
        max-width: 1024px;
        max-height: 526px;

        background-color: var(--color-dark);
        color: var(--color-light);
      }

      .social-card__wrapper {
        display: flex;
        flex-direction: column;
        justify-content: center;
        padding: 5rem;
      }

      h1 {
        font-size: 4rem;
        line-height: 1;
        max-width: 30ch;
      }
    </style>
  </head>
  <body>
    <main>
      <div class="social-card">
        <div class="social-card__wrapper">
          <h1>
            {entry.data.title}
          </h1>
        </div>
      </div>
    </main>
  </body>
</html>

You’ll end up with something similar to this:

A preview of a social media share card displaying the title of a blog post on a dark background. The text is centered and styled with large, bold typography, and the image dimensions are designed to fit the required size for the screenshot I will capture using Urlbox.

It’s a simple example, but it gets the idea across.

You can get creative here—add metadata like author names, your site’s logo, or anything else that gives the card personality. Just be mindful of font sizing and make sure long titles or other text won’t overflow or break out of this fixed layout.

The key caveat is to ensure everything fits within the width and height we’ve chosen for capturing screenshots. For this, my defaults are a width of 1024px and height of 526px. More on this next!

Configuring the BaseLayout template

In every project, I have a BaseLayout.astro, which serves as an astro layout acting as the shell for all my pages. This includes the <html>, <head> and <body> tags etc.

A very basic example of this template will look like:

BaseLayout.astro

---
const { title } = Astro.props;
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
  </head>
  <body>
    <main tabindex="-1" id="main-content">
      <slot />
    </main>
  </body>
</html>

Normally, I’d abstract all my metadata into its own partial, but for now let’s just add this to our BaseLayout including only the open graph image tags for simplicity. In addition I’ll be creating a JavaScript helper too.

BaseLayout.astro

---
import { getUrlboxImageFromCurrentPath } from '@/helpers/getUrlboxImageFromCurrentPath.js';

const { title, summary, canonicalURL, allowRobots = true } = Astro.props;

const siteOrigin = Astro.url.origin || 'https://your-site.com';
const pathname = Astro.url?.pathname || '/';
const ogImageURL = getUrlboxImageFromCurrentPath(pathname, siteOrigin);
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>

    {
      ogImageURL && (
        <>
          <meta name="twitter:card" content="summary_large_image" />
          <meta property="og:image" content={ogImageURL} />
          <meta name="twitter:image" content={ogImageURL} />
          <meta property="og:image:alt" content={`Page image for ${title}`} />
          <meta name="twitter:image:alt" content={`Page image for ${title}`} />
        </>
      )
    }
  </head>
  <body>
    <main tabindex="-1" id="main-content">
      <slot />
    </main>
  </body>
</html>

I’ll add the helper here: src/helpers/getUrlboxImageFromCurrentPath.js.

getUrlboxImageFromCurrentPath.js

import * as crypto from 'crypto';

/**
 * Generates a Urlbox OG image URL based on the current page path.
 * @param {string} pathname - e.g. "/writing/my-post/"
 * @param {string} origin - e.g. "https://your-site.com"
 * @returns {string} Full Urlbox image URL
 */
export function getUrlboxImageFromCurrentPath(pathname, origin) {
  // Ensure trailing slash unless root
  const normalizedPath = pathname === '/' ? '/' : pathname.endsWith('/') ? pathname : `${pathname}/`;

  // Prepend /og to mimic image-generating route
  const ogPath = `/og${normalizedPath}`;

  const ogURL = `${origin}${ogPath}`;
  const options = `url=${ogURL}&width=1024&height=526&retina=true`;

  const token = crypto
    .createHmac('sha256', import.meta.env.URLBOX_SECRET_KEY)
    .update(options)
    .digest('hex');

  return `https://api.urlbox.io/v1/${import.meta.env.URLBOX_API_KEY}/${token}/png?${options}`;
}

There’s quite a lot going on here, so let’s break it down.

A quick look at the helper

The getUrlboxImageFromCurrentPath helper looks a bit involved, but it’s really just building a secure URL to tell Urlbox what to screenshot and how.

How do we do this?

  1. Normalise the path

We make sure the URL path ends with a slash, just to keep things consistent:

const normalizedPath = pathname === '/' ? '/' : pathname.endsWith('/') ? pathname : `${pathname}/`;
  1. Point to the OG route

We add /og to the front so Urlbox screenshots our custom OG page:

const ogPath = `/og${normalizedPath}`;
const ogURL = `${origin}${ogPath}`;
  1. Set screenshot options

Here we define the size and enable high-res resolution:

const options = `url=${ogURL}&width=1024&height=526&retina=true`;

Remember earlier when we decided what dimensions to set the .social-card? This is where they become important.

  1. Sign the request

Urlbox needs each request to be signed using your secret key:

const token = crypto
  .createHmac('sha256', import.meta.env.URLBOX_SECRET_KEY)
  .update(options)
  .digest('hex');

It’s worth pointing out here that our credentials will be stored in an .env file. You can read more about those here.

  1. Return the full image URL

We combine everything into the final image URL used in your OG meta tags:

return `https://api.urlbox.io/v1/${import.meta.env.URLBOX_API_KEY}/${token}/png?${options}`;

That’s it! With a little setup, you have dynamic OG images that feel native to your content and require zero manual effort per page.


back