How to Implement Dynamic OG Images in Next.js, Remix, or Laravel: A Step-by-Step Guide
Why Your Static OG Image Is Costing You Clicks
When you share a link on LinkedIn, X, Slack, WhatsApp, or Facebook, the platform crawls your page and pulls the og:image meta tag. If that image is static — the same generic banner for every URL — you're leaving clicks on the table.
Here's the problem: a static OG image says nothing about what the reader will get. "How to Implement Dynamic OG Images" with a generic gradient tells me nothing. "How to Implement Dynamic OG Images — 7 Steps, 15 Minutes, Zero Server Setup" tells me everything. The latter gets the click.
Dynamic OG images — generated per-URL with the actual title, author, price, or rating baked in — convert better because they set accurate expectations. The crawler sees a preview that matches the content, so the person on the other end knows exactly what they're opening.
But here's the catch: social crawlers don't execute JavaScript. Client-side rendering fails silently. WhatsApp, Twitter, and LinkedIn all fetch your URL server-side and read the raw HTML. If your OG image is generated client-side, you get a blank preview or a broken image.
The fix is a server-side endpoint that returns a real image. FastOG does exactly this: you call one URL with query parameters, and you get back a ready-to-share 1200×630 PNG, JPEG, or WebP — rendered server-side by headless Chrome, not assembled client-side.
Prerequisites: What You Need Before You Start
Before you wire anything up, you need:
- A FastOG account — you get 100 free credits on signup, no credit card required
- Your API key and secret — found in your dashboard; each key has its own HMAC secret
- A framework — Next.js (App or Pages Router), Remix, or Laravel; the pattern is identical across all three
-
A template ID — FastOG ships 41 server-side Svelte 5 templates (
blog,product,pricing,saaslaunch,stats,bento,comparison,countdown,promo,ecommerce,game,news,podcast,video,devblog,codepost, and more)
The core endpoint is GET /api/v1/og. Each render costs 1 credit, and the image is cached for a year (Cache-Control: public, max-age=31536000, immutable), so everyone after the first request gets it for free.
Step 1: Set Up Your Dynamic Image Endpoint
The pattern is the same in every framework: accept the incoming request, build the OG image URL with your parameters, sign it, and return it as the og:image meta tag.
Here's the canonical flow in Laravel:
// routes/web.php
Route::get('/post/{slug}', function ($slug) {
$post = Post::where('slug', $slug)->firstOrFail();
$ogUrl = 'https://api.fastog.com/api/v1/og?' . http_build_query([
'template' => 'blog',
'title' => $post->title,
'subtitle' => $post->excerpt,
'author' => $post->author_name,
'key' => config('services.fastog.key'),
's' => sign_og_url($post), // see Step 4
]);
return view('post', [
'post' => $post,
'ogImage' => $ogUrl,
]);
});
In Next.js App Router, you'd do the same inside a generateMetadata function:
// app/post/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
const ogUrl = buildOgUrl({
template: 'blog',
title: post.title,
subtitle: post.excerpt,
author: post.author_name,
});
return {
title: post.title,
openGraph: {
images: [{ url: ogUrl, width: 1200, height: 630 }],
},
};
}
And in Remix, inside a loader or meta export:
export const meta: MetaFunction = ({ data }) => {
return [
{ title: data.post.title },
{ property: 'og:image', content: buildOgUrl(data.post) },
];
};
The key insight: the OG image URL is just a URL. Any framework that can emit a <meta> tag can use it.
Step 2: Design Your Template and Pass Data Safely
FastOG renders templates server-side with Svelte 5 at exactly 1200×630 using headless Chrome (Browsershot) in a separate Node render service. You don't touch the rendering pipeline — you just pass data.
The available query parameters are: title, subtitle, template, price, original_price, rating, features, badge, image, and more, depending on the template.
Security rule: treat the OG URL as untrusted input. Never pass raw user content without encoding. FastOG signs the canonical sorted query with RFC 3986 encoding, so the signature covers every parameter. If you're building the URL yourself, use URLSearchParams or http_build_query — don't hand-concatenate strings.
A safe pattern:
function buildOgUrl(params: Record<string, string>): string {
const search = new URLSearchParams({
...params,
key: process.env.FASTOG_KEY!,
});
search.set('s', sign(params)); // HMAC signature
return `https://api.fastog.com/api/v1/og?${search.toString()}`;
}
Step 3: Handle Caching and Edge Storage
This is where FastOG's economics get interesting. Every render costs 1 credit, but the image is cached for a year. The first request for a given URL pays the credit; everyone after that gets the cached copy for free.
The response includes Cache-Control: public, max-age=31536000, immutable. That means:
- Social crawlers (LinkedIn, Twitter, Facebook) hit the cached copy — fast, no credit cost
- Your users sharing the link also hit the cache
- You don't pay for repeat renders
If you're generating OG images for a high-traffic site, this is the difference between $0.0014 per image and effectively $0 after the first render.
Step 4: Add HMAC Signing for Security
This is the part most tutorials skip, and it's the one that matters. FastOG requires every request to carry key + s (signature). The signature lives in the URL, not in headers, because social network crawlers don't send custom headers.
The signing algorithm:
- Take all query parameters except
s - Sort them alphabetically by key
- RFC 3986-encode each key and value
- Concatenate as
key=value&key=value - HMAC-SHA256 the result with your API secret
Here's a working implementation in Node.js:
import crypto from 'crypto';
export function signOgUrl(params: Record<string, string>, secret: string): string {
const sorted = Object.keys(params)
.filter(k => k !== 's')
.sort()
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
return crypto.createHmac('sha256', secret).update(sorted).digest('hex');
}
And in PHP:
function signOgUrl(array $params, string $secret): string {
ksort($params);
$canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
return hash_hmac('sha256', $canonical, $secret);
}
Why bother? Because without signing, anyone can burn your credits by hitting your endpoint with arbitrary parameters. With signing, only requests you authorized render.
Step 5: Test Across All Major Platforms
Different platforms cache OG images differently. Here's what to check:
- LinkedIn: notoriously aggressive caching. Use their Post Inspector to force a refresh.
- X (Twitter): use the Card Validator. It shows you exactly what the crawler sees.
-
WhatsApp: the most frustrating — it caches aggressively and has no public debugger. Append
?v=2to your URL to bust the cache. - Facebook: the Sharing Debugger lets you scrape fresh and see errors.
FastOG also has a free OG Image Tester — no signup required — that previews your image client-side and downloads a real PNG. It's the fastest way to sanity-check a template before you deploy.
Step 6: Go Live with Monitoring and Analytics
Once you're live, watch these signals:
-
X-Renders-Remainingheader on every response — tells you your credit balance -
X-FastOG-Watermarkheader — confirms whether the image is watermarked -
Error codes —
402(insufficient credits),422(validation error),502(render service unreachable),503(service unavailable). Errors cost nothing — deduction happens only after a successful render, with an atomic lock preventing double-spend.
If you see a spike in 502s, the render service may have wedged. FastOG runs a self-healing watchdog (og:health) that auto-restarts the Docker container, but you should still monitor your error rate.
Troubleshooting Common Issues: Empty Previews, Caching, and Quotas
"My preview is blank on WhatsApp but works on X" — WhatsApp caches aggressively and doesn't honor cache-busting headers. Append a query param (?v=timestamp) to force a fresh fetch.
"The image is stale on LinkedIn" — Use LinkedIn's Post Inspector to force a re-scrape. It's the only reliable way.
"I'm out of credits" — Check X-Renders-Remaining. Cached images are free, so the fix is usually to ensure your cache headers are being respected. If you're genuinely out, credit packs start at $2.
"My signature doesn't validate" — The most common cause is encoding mismatch. Make sure you're using RFC 3986 encoding (not encodeURIComponent's default) and that your parameters are sorted before signing.
"I'm getting 422s" — You're passing an invalid parameter or template name. Check the template list and your parameter names against the API reference.
Dynamic OG images are a small change with an outsized impact on click-through. The pattern is identical whether you're on Next.js, Remix, or Laravel: build a signed URL, emit it as og:image, and let the server-side renderer do the work. FastOG handles the rendering, caching, and security — you just ship the meta tag.
Top comments (0)