Adding Autogenerated Opengraph Meta Images to Remix


After getting the SEO meta data set up right and getting some traction from search engines, I wanted to also have an accompanying image that is going to be displayed whenever content from this blog is shared. I don't want to have to generate and upload images manually for each blog post I create, but rather have them autogenerated from the data I have on each post (title and date, for starters). After some research, I found out about the @resvg/resvg-js and the satori packages and how they can be used to generate the images for me.

How this works is we basically create html that will be later converted to an image and then serve that as the og:image tag. We will need a couple of things to get that set up. First, let's create a server file that will generate the image based on the data we are providing (title and date) that also contains a static template (in this case I use my logo and another image).

I put my file on the features/generateImage/generateOGImage.server.tsx route, but anywhere is fine, it's just important to add the .server suffix to the file name to let Remix know it's not a file accessible from the frontend. This is my contents of that file (with added comments for clarification):

import { Resvg } from "@resvg/resvg-js";
import type { SatoriOptions } from "satori";
import satori from "satori";
import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "~/routes/resource/ogimage";

// Load the font and images from the public folder and return the results
const fontSans = (baseUrl: string) =>
fetch(new URL(`${baseUrl}/font/Inter-ExtraBold.otf`)).then((res) =>
const avatarImage = (baseUrl: string) =>
fetch(new URL(`${baseUrl}/images/Luka-Lazic.png`)).then((res) =>

const logoImage = (baseUrl: string) =>
fetch(new URL(`${baseUrl}/images/logo-inverted.png`)).then((res) =>

// export the function that will generate the image, it takes a request url as a parameter so this will work both in development mode and after the app is deployed
export async function generateOGImage(
title: string,
requestUrl: string,
date: string
) {
const fontSansData = await fontSans(requestUrl);
const avatar = await avatarImage(requestUrl);
const logo = await logoImage(requestUrl);

// The function that converts the date object to a humanly readable format, it can be tweaked to whatever format you prefer
const dateToDisplay = date ? new Date(date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}) : "";

// You can change your desired image width and height here as well as the font
const options: SatoriOptions = {
    width: OG_IMAGE_WIDTH,
    height: OG_IMAGE_HEIGHT,
    fonts: [
        name: "Inter",
        data: fontSansData,
        style: "normal",

// Generate the new image with "satori". The design can be anything you want
const svg = await satori(
width: options.width,
height: options.height,
background: "rgb(17 24 39)",
color: "white",
fontFamily: "Inter",
fontSize: 70,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",

position: "absolute",
left: 0,
bottom: 0,
width: "350px",
height: "350px",

padding: "0 50px 100px 0",
width: "70%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
<div style={{ fontSize: 30, display: "flex" }}>{dateToDisplay}</div>

position: "absolute",
left: "50px",
top: "50px",
width: "270px",
height: "85px",

// Convert to PNG and return the image
const resvg = new Resvg(svg);
const pngData = resvg.render();
return pngData.asPng();

This file will generate the image, but it's a server file so we need to create a route where we can access it. I have put mine in app/routes/resource/ogimage.ts with the following code:

import type { LoaderArgs } from "@remix-run/node";
import { generateOGImage } from "~/features/generateImage/generateOGImage.server";

export const OG_IMAGE_WIDTH = 1200;
export const OG_IMAGE_HEIGHT = 630;

export const loader = async ({ request }: LoaderArgs) => {
const { origin, searchParams } = new URL(request.url);

// We get the title and date parameters from the URL, which will be explained soon
const title = searchParams.get("ogimage") ?? `Hello world`;
const date = searchParams.get("ogdate") ?? "";

// Here we call our image generation function from the server file
const png = await generateOGImage(title, origin, date);

// Then we return a response which is the generated image that we will use in a bit!
return new Response(png, {
status: 200,
headers: {
"Content-Type": "image/png",
// The cache control header is commented out, it is recommended to use it in production. While tinkering with this, I suggest leaving it commented out
// "cache-control": "public, immutable, no-transform, max-age=30000000",


Next, we need to add this to the loader function of our posts. In my case, it looks like this:

export const loader: LoaderFunction = async ({ request, params }) => {
  const post = await getPost(params.postSlug);
  invariant(post, `Post not found: ${params.postSlug}`);

// We create a request to the ogImage file from above, with the title and the date
  const { origin } = new URL(request.url);
  const ogImageUrl = `${origin}/resource/ogimage?ogimage=${post.title}&ogdate=${post.createdAt}`;


  const html = marked(post.markdown);
  // We also export the url we are getting so we can use it in the Meta Function
  return json<LoaderData>({ post, html, canonical, ogImageUrl });


In the same file, when exporting the meta function, I am using the image that is returned:

export const meta: MetaFunction = ({ data }) => {
  return {
    // First all the other meta data data
    title: + " | Luka Lazic Blog",
    "og:url": data.canonical,
    "og:type": "article",
    // Then here we are adding the image, both the general one and for twitter
    "og:image": data.ogImageUrl,
    "twitter:image": data.ogImageUrl,

And there you have it! Now we have a nice image and we are ready to share our posts. The template can be further updated and changed depending on your requirements.

Share this post :