skip to content
ainoya.dev

Cache Satori-generated OGP images in Astro Cactus to speed up builds

/ 5 min read

Table of Contents

Astro Cactus ships with dynamic Open Graph (OGP) images: for each post, the theme renders a title card using Satori (to SVG) and Resvg (to PNG). It’s convenient—but if every image is regenerated on every build, the OGP step adds up. On my machine, each image takes roughly 100–300 ms, which becomes noticeable with dozens of posts.

This post shows a simple, file-based cache that avoids re-rendering unchanged images. The approach is deterministic, CI-friendly, and requires no extra services.

How it works

  • We compute a content hash from the post’s title and date.
  • We store the rendered PNG in node_modules/.og_image_cache/<hash>.png.
  • On the next build, if the PNG exists, we reuse it; otherwise we render and save.

The cache key changes whenever the title or date changes, so the image is re-rendered only when necessary.

The code

import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { Resvg } from "@resvg/resvg-js";
import type { APIContext } from "astro";
import satori, { type SatoriOptions } from "satori";
import { html } from "satori-html";
import RobotoMonoBold from "@/assets/roboto-mono-700.ttf";
import RobotoMono from "@/assets/roboto-mono-regular.ttf";
import { getAllPosts } from "@/data/post";
import { siteConfig } from "@/site.config";
import { getFormattedDate } from "@/utils/date";
const ogOptions: SatoriOptions = {
// debug: true,
fonts: [
{ data: Buffer.from(RobotoMono), name: "Roboto Mono", style: "normal", weight: 400 },
{ data: Buffer.from(RobotoMonoBold), name: "Roboto Mono", style: "normal", weight: 700 },
],
height: 630,
width: 1200,
};
// Cache directory: keep it out of git, easy to persist in CI.
const CACHE_DIR = path.join(process.cwd(), "node_modules", ".og_image_cache");
// Ensure cache directory exists.
function ensureCacheDir() {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
// Create a short, deterministic content hash.
function generateContentHash(title: string, pubDate: Date): string {
const content = `${title}-${pubDate.toISOString()}`;
return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
}
// Lookup or render the PNG, then persist it.
async function getCachedOrGeneratePng(
title: string,
pubDate: Date,
postDate: string,
): Promise<Uint8Array> {
ensureCacheDir();
const contentHash = generateContentHash(title, pubDate);
const cacheFilePath = path.join(CACHE_DIR, `${contentHash}.png`);
if (fs.existsSync(cacheFilePath)) {
console.log(`Using cached OG image for: ${title}`);
return new Uint8Array(fs.readFileSync(cacheFilePath));
}
console.log(`Generating new OG image for: ${title}`);
const svg = await satori(markup(title, postDate), ogOptions);
const png = new Resvg(svg).render().asPng();
const pngBuffer = new Uint8Array(png);
fs.writeFileSync(cacheFilePath, pngBuffer);
return pngBuffer;
}
const markup = (title: string, pubDate: string) =>
html`<div tw="flex flex-col w-full h-full bg-[#1d1f21] text-[#c9cacc]">
<div tw="flex flex-col flex-1 w-full p-10 justify-center">
<p tw="text-2xl mb-6">${pubDate}</p>
<h1 tw="text-6xl font-bold leading-snug text-white">${title}</h1>
</div>
<div tw="flex items-center justify-between w-full p-10 border-t border-[#2bbc89] text-xl">
<div tw="flex items-center">
<p tw="font-semibold">${siteConfig.title}</p>
</div>
<p>by ${siteConfig.author}</p>
</div>
</div>`;
export async function GET(context: APIContext) {
const { pubDate, title } = context.props;
const postDate = getFormattedDate(pubDate, {
month: "long",
weekday: "long",
});
const pngBuffer = await getCachedOrGeneratePng(title, pubDate, postDate);
const body = pngBuffer.slice(0).buffer;
return new Response(body, {
headers: {
// Adjust cache headers as needed for your use case.
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "image/png",
},
});
}
export async function getStaticPaths() {
const posts = await getAllPosts();
return posts
.filter(({ data }) => !data.ogImage) // skip if a custom image is set
.map((post) => ({
params: { slug: post.id },
props: {
pubDate: post.data.updatedDate ?? post.data.publishDate,
title: post.data.title,
},
}));
}

What each piece does

  • Cache directory
    • node_modules/.og_image_cache is ignored by git and easy to persist across CI runs. If your CI re-installs dependencies from scratch, persist this path between builds to get the benefit.
  • Cache key
    • `sha256(title + ISO(pubDate)), first 16 hex chars. If the title or date changes, we get a new key and the image is regenerated.
  • SVG → PNG
    • Satori returns SVG; Resvg rasterizes to PNG. Fonts are embedded via SatoriOptions so the output is deterministic.
  • Endpoint behavior
    • The endpoint uses getStaticPaths() with props so the images are pre-rendered at build time (SSG). The Cache-Control header is for browser/CDN caching and is separate from the on-disk cache.
  • Updated posts
    • We pass updatedDate ?? publishDate to the hash. Any content update that bumps updatedDate will invalidate the image.

Operational notes

  • CI persistence
    • If builds happen in a clean environment, configure your CI cache to persist node_modules/.og_image_cache. No additional services are needed.
  • When to invalidate
    • Decide what should trigger a new image. Title + date works for most blogs. If you frequently tweak the template, include a version salt (see below).
  • Growth management
    • Over long periods, the cache can grow. Consider a small cleanup script to remove files older than N days.

Optional improvements

  1. Add a template/version salt

If you change fonts, colors, or layout, you likely want all images to refresh. Add a constant to the hash:

const CACHE_VERSION = "v1"; // bump when markup/fonts change
function generateContentHash(title: string, pubDate: Date): string {
const content = `${CACHE_VERSION}|${title}|${pubDate.toISOString()}`;
return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
}
  1. Hash the actual markup

For the most robust invalidation, hash the full SVG input (including site title/author/date formatting):

const svgInput = markup(title, postDate);
const contentHash = crypto.createHash("sha256").update(JSON.stringify({ svgInput })).digest("hex").slice(0, 16);
  1. Store pre-renders under public/og/

If you prefer visible, versioned assets that can be uploaded to a CDN or backed up outside node_modules, write PNGs to public/og/<hash>.png and reference them by URL. The trade-off is repo churn or extra cleanup if you commit them.

  1. Concurrency controls for very large blogs

When building hundreds of images in parallel, Resvg can saturate CPU. A simple promise pool (e.g., p-limit) around your generation logic can keep the build smooth.

  1. Font and license hygiene

Keep fonts local and embed them via SatoriOptions as shown. Make sure your font license permits embedding in generated images.

Results

With caching enabled, unchanged posts reuse their OGP images immediately. On my setup the OGP step dropped by roughly 100–300 ms per image that didn’t need regeneration, which noticeably reduced total build time as the post count grew.

Footnotes & pointers

  • Astro endpoints are called at build time in SSG and can generate static files; they become live routes in SSR.
  • Satori converts HTML/CSS to SVG; Resvg rasterizes SVG to PNG.