If you are building with Astro, SvelteKit or Remix, the standard advice for dynamic OG images points to Satori. But Satori is JSX-only, flexbox-only, and needs WASM and manual font fetching on every framework that isn't Next.js. Each framework ends up re-implementing the same brittle pipeline with its own breakage. There is a simpler way: treat OG generation as one HTTP layer that works identically everywhere, then cache the result and stop re-rendering per crawler hit.
Satori sits at approximately 1 million weekly downloads, with direct usage mostly from developers on non-Next.js frameworks like Hono, Astro and SvelteKit [1].
In Astro, Satori's WASM import works on Cloudflare Workers but breaks during development with Vite, so you must build the site and run wrangler dev instead of astro dev [2].
In SvelteKit, Satori only accepts ReactElement (JSX) input and options [3]. You need satori-html to convert HTML into ReactElement syntax before Satori can process it, then @resvg/resvg-js to convert the resulting SVG to PNG because SVG is not meant to be served as OG images [3]. A commenter on the standard SvelteKit Satori tutorial notes the approach won't work on the Edge because Vercel refuses to fix it, linking satori issue #582 [3].
In Remix, importing @vercel/og results in a cryptic error, and the straightforward alternative is to use the lower-level dependencies satori and resvg directly [4]. Vercel has stated plans to introduce a Vercel-flavored Remix dev command but it hasn't happened yet [5]. You end up siloing satori and resvg into a server-side-only file so they aren't bundled into the client, and fetching the font from the public directory at render time [6].
Common to all three: Satori requires fonts to render text and currently supports only TTF, OTF and WOFF files [6]. No WOFF2 support. And the Remix tutorial includes a commented-out tip to heavily cache the response in production with a max-age of 31536000 [6], because without that header you are re-rendering the same image on every crawler hit.
MarkupGo's Magic Template URL lets you generate images from a template via a plain URL with query parameters, with no API call or code required [7]. MarkupGo positions this as best suited for generating OG-tag images and social media posts [7]. The Node client's image.fromHtml, fromTemplate, fromUrl and fromMarkdown methods each return either a JSON task or a raw buffer [7].
Install the client and initialize it with your API key:
npm install markupgo-node
import MarkupGo from "markupgo-node";
const markupgo = new MarkupGo({
API_KEY: "your_api_key_here",
});
For a server-side route, call fromTemplate with your template ID and context:
const templateData = {
id: "YOUR_TEMPLATE_ID",
context: {
title: "Dynamic OG Title",
description: "Framework-agnostic social card"
}
};
const imageOptions = {
properties: {
format: "png",
width: 1200,
height: 630
}
};
const task = await markupgo.image.fromTemplate(templateData, imageOptions).json();
// task.url contains the hosted image URL on files.markupgo.com
Or skip the Node client entirely. Build your template in the MarkupGo dashboard, then construct a Magic Template URL:
https://render.markupgo.com/template/${YOUR_TEMPLATE_ID}.png?episode=5&title=Let's%20get%20json%20response%20and%20use%20the%20task%20URL&json=true
Adding json=true returns a JSON task response containing a hosted image URL instead of the image itself [8]. Each Magic URL conversion costs one credit, but once the URL is generated it can be used multiple times without additional credits [7]. Generated images can be set to auto-delete via an expiration field, with expiresInSeconds ranging from 60 seconds to 90 days [9].
Drop the returned URL straight into meta tags in Astro, SvelteKit, Remix or Nuxt.
MarkupGo's paid plans start at Lite $29/month for 2,000 credits and 5 templates, Plus $49/month for 10,000 credits and 30 templates, and Pro $99/month for 20,000 credits with unlimited templates [7]. The free trial grants 100 credits every month, available indefinitely with limited features and no credit card required [7].
Fetch the Magic Template URL at build time or in a server endpoint. In a server endpoint (src/pages/og/[...slug].ts or an API route), call the MarkupGo API or fetch the Magic URL with json=true, then pass the returned image URL to your layout's meta tags.
---
// src/layouts/BlogPost.astro
// Fetch in a server endpoint, or pass the pre-generated URL as props
const { title, description } = Astro.props;
// see Markupgo's API reference for the exact Magic Template URL format
const ogImageUrl = `https://render.markupgo.com/template/${YOUR_TEMPLATE_ID}.png?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&json=true`;
const response = await fetch(ogImageUrl);
const task = await response.json();
// see Markupgo's API reference for error handling
---
<meta property="og:image" content={task.url} />
<meta name="twitter:card" content="summary_large_image" />
Use a server load function to call fromTemplate or fetch the Magic URL with json=true, return the image URL to +page.svelte for the og:image meta tag.
// +page.server.js
import MarkupGo from "markupgo-node";
const markupgo = new MarkupGo({ API_KEY: process.env.MARKUPGO_API_KEY });
export async function load({ params }) {
const post = await getPost(params.slug); // your CMS call
const task = await markupgo.image.fromTemplate({
id: process.env.OG_TEMPLATE_ID,
context: {
title: post.title,
description: post.excerpt
}
}, {
properties: { format: "png", width: 1200, height: 630 }
}).json();
return { ogImage: task.url };
}
<!-- +page.svelte -->
<script>
export let data;
</script>
<svelte:head>
<meta property="og:image" content={data.ogImage} />
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
Handle OG generation in a loader, return the image URL to the meta export function.
// app/routes/blog.$slug.tsx
import MarkupGo from "markupgo-node";
const markupgo = new MarkupGo({ API_KEY: process.env.MARKUPGO_API_KEY });
export async function loader({ params }) {
const post = await getPost(params.slug);
const task = await markupgo.image.fromTemplate({
id: process.env.OG_TEMPLATE_ID,
context: {
title: post.title,
description: post.excerpt
}
}, {
properties: { format: "png", width: 1200, height: 630 }
}).json();
return json({ post, ogImage: task.url });
}
export const meta = ({ data }) => {
return [
{ property: "og:image", content: data.ogImage },
{ name: "twitter:card", content: "summary_large_image" }
];
};
The recommended caching pattern for dynamic OG images is generating on first request with a long Cache-Control header (max-age=31536000, s-maxage=31536000) and a content-hash in the URL, producing effectively permanent CDN caches [1]. With MarkupGo, you get this by storing the returned task.url and serving that same URL permanently. The image is already hosted on files.markupgo.com; your job is to avoid re-calling the API on every request.
Better yet, generate at build or publish time. When your CMS publishes a post, trigger a build hook that calls MarkupGo once, stores the returned URL in your database or KV store, and serves that stored URL forever. No per-request rendering, no cold starts, no credit spend per crawler hit.
Satori is the right call for simple cards on Vercel with an existing React setup. If you are already in Next.js with @vercel/og, the integration is trivial and the constraints are manageable.
Switch when you need real CSS. Grid layouts, pseudo-elements, WOFF2 fonts, emoji, or any JavaScript-driven rendering.
The URL/API approach has independent validation. The og.hjlabs.in hosted OG API offers a free tier of 50 images/day with no signup, a $12/mo Pro tier with 10K calls/month, runs on Cloudflare's edge, and works with Astro, Nuxt, SvelteKit, Remix, WordPress and static HTML.
Sign up for the free trial at markupgo.com, create an OG template in the dashboard, and generate your first Magic Template URL.