Dynamic OG Images Without Next.js or Vercel
Most guides assume you're using Next.js and deploying to Vercel. But what if you're running Rails, Django, Laravel, Hugo, Astro, or a plain static site? Here's how to generate dynamic social preview images with any stack.
The problem with framework-specific solutions
Vercel's @vercel/og package is excellent — if you're already in the Vercel ecosystem. It uses Satori under the hood to render React components to images at the edge.
But it comes with real constraints:
- Vercel lock-in — it runs on Vercel Edge Functions. You can't deploy it to AWS, GCP, or your own servers without significant rework.
- React required — your OG image templates must be JSX components. If your app is Python, Ruby, Go, or PHP, you now need a Node.js sidecar just for images.
- Build-time complexity — you need to manage fonts, configure the runtime, handle caching, and debug edge function cold starts.
- Self-hosted maintenance — if you self-host Satori, you're responsible for font loading, memory management, SVG edge cases, and keeping dependencies updated.
There's a simpler approach: treat OG image generation as an external API call, just like you'd use Stripe for payments or Twilio for SMS.
The API approach: one URL, any stack
Instead of running image generation infrastructure, you can use a dedicated API. The idea is simple: construct a URL with your parameters, and the API returns a PNG.
Here's what that looks like with OGPeek:
https://ogpeek.com/api/v1/og?title=My+Blog+Post&subtitle=Published+March+2026&template=gradient&theme=midnight&brandColor=%23FF7A00
That URL is the image. Drop it into any <meta> tag:
<meta property="og:image"
content="https://ogpeek.com/api/v1/og
?title=My+Blog+Post
&template=gradient
&theme=dark" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
That's it. No build step. No dependencies. No deployment pipeline. Works the same whether your site is built with Hugo, Jekyll, WordPress, Django, Rails, or hand-written HTML.
Framework-by-framework examples
Ruby on Rails
In your layout or view, construct the URL dynamically from your model data:
<%# app/views/layouts/application.html.erb %>
<meta property="og:image"
content="https://ogpeek.com/api/v1/og?title=<%= ERB::Util.url_encode(@post.title) %>&subtitle=<%= ERB::Util.url_encode(@post.published_at.strftime('%B %Y')) %>&template=gradient&theme=dark" />
Django / Python
Use Django's template engine with urlencode:
{# templates/blog/post.html #}
<meta property="og:image"
content="https://ogpeek.com/api/v1/og?title={{ post.title|urlencode }}&subtitle={{ post.date|date:'F Y'|urlencode }}&template=gradient&theme=midnight" />
Laravel / PHP
In a Blade template:
{{-- resources/views/post.blade.php --}}
<meta property="og:image"
content="https://ogpeek.com/api/v1/og?title={{ urlencode($post->title) }}&subtitle={{ urlencode($post->published_at->format('F Y')) }}&template=gradient&theme=dark" />
Hugo (Go templates)
Hugo's templating makes this straightforward:
{{/* layouts/partials/og.html */}}
<meta property="og:image"
content="https://ogpeek.com/api/v1/og?title={{ .Title | urlquery }}&subtitle={{ .Date.Format "January 2006" | urlquery }}&template=gradient&theme=midnight" />
Astro
In your Astro layout's frontmatter, build the URL and pass it to the template:
---
// src/layouts/BlogPost.astro
const { title, date } = Astro.props;
const ogUrl = `https://ogpeek.com/api/v1/og?title=${encodeURIComponent(title)}&subtitle=${encodeURIComponent(date)}&template=gradient&theme=dark`;
---
<meta property="og:image" content={ogUrl} />
Static sites / plain HTML
If you're generating pages with a script or a static site generator, just hardcode the URL with the right parameters per page. The API handles the rest.
Social crawlers (Twitter, LinkedIn, Facebook, Slack) fetch the og:image URL when someone shares a link. They don't care how the image was generated. A URL that returns a PNG is a URL that returns a PNG, regardless of your backend technology.
Server-side generation with POST
For paid tiers (no watermark, higher limits), use a POST request with your API key. This is useful for pre-generating images in a build step or background job:
const response = await fetch('https://ogpeek.com/api/v1/og', {
method: 'POST',
headers: {
'x-api-key': process.env.OGPEEK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: post.title,
subtitle: post.date,
template: 'gradient',
theme: 'midnight',
brandColor: '#FF7A00',
}),
});
const png = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(`public/og/${post.slug}.png`, png);
import requests, os
response = requests.post(
"https://ogpeek.com/api/v1/og",
headers={"x-api-key": os.environ["OGPEEK_API_KEY"]},
json={
"title": post.title,
"subtitle": post.published_at.strftime("%B %Y"),
"template": "gradient",
"theme": "midnight",
}
)
with open(f"static/og/{post.slug}.png", "wb") as f:
f.write(response.content)
When to use GET vs POST
- GET — for meta tags. The URL is the image. Crawlers fetch it directly. Free tier supports this with a small watermark.
- POST — for pre-generating images in CI/CD, build scripts, or background jobs. Requires an API key. No watermark on paid tiers.
Most teams start with GET in meta tags (zero infrastructure) and graduate to POST when they need watermark-free images or want to cache locally.
Performance comparison
Here's how the API approach compares to self-hosting:
- Self-hosted Puppeteer/Playwright: 2-5 seconds per image. Requires a headless browser, significant memory, and a running server. Cold starts on serverless can push this to 10+ seconds.
- Self-hosted Satori: 100-500ms per image. Better than Puppeteer, but you still manage dependencies, fonts, and edge cases. Memory leaks are common in long-running processes.
- OGPeek API: <200ms per image. No infrastructure to manage. Pay only for what you use. The pipeline uses Satori + resvg-js (no headless browser).
Handling edge cases
Long titles
The API automatically truncates and adjusts font sizes for long titles. You don't need to handle this in your template.
Special characters
URL-encode your parameters. Most template engines have a built-in filter (urlencode, urlquery, encodeURIComponent). Ampersands, quotes, and Unicode all work correctly when encoded.
Caching
Social crawlers cache aggressively. If you change a title, the old image may persist for hours. Two strategies:
- Cache-busting parameter: append
&v=2to the URL when content changes. - Pre-generate with POST: save the PNG to your own CDN and point the meta tag there.
Try it now
Generate your first OG image in under a minute. Free tier, no credit card.
Open the playgroundSummary
You don't need Next.js, Vercel, or a headless browser to generate dynamic OG images. An API-based approach works with any tech stack, requires zero infrastructure, and takes five minutes to integrate.
The URL-as-API pattern is the same one that made services like Stripe and Twilio successful: do one thing well, expose it as a simple HTTP interface, and let developers integrate it however they want.