OG Images in JavaScript
Generate og:image in JavaScript with the FastOG API — with a Dynamic API subscription (create your API key in the dashboard), subscription-based, HMAC-secured. This guide shows you how to create a signer utility for HMAC-signed OG image URLs and render dynamic Open Graph images on the fly. Try the Free OG Image Tester to preview before you ship.
Table of Contents
- Quick Start (5 minutes)
- Next.js App Router
- Next.js Pages Router
- Express.js Backend
- Vanilla Node.js
- Frontend Integration Patterns
- Testing & Debugging
- Common Pitfalls
Quick Start
Goal: Generate a signed OG image URL in 5 minutes.
Step 1: Install Dependencies
npm install crypto # Built-in, no install needed
Step 2: Create Signer Utility
// lib/og-image-signer.js
import crypto from "crypto";
export function generateSignedOgUrl(params, options = {}) {
const {
baseUrl = "https://fastog.com/api/v1/og",
apiKey = process.env.FASTOG_API_KEY,
hmacSecret = process.env.FASTOG_HMAC_SECRET,
} = options;
if (!apiKey || !hmacSecret) {
throw new Error("Missing FASTOG_API_KEY or FASTOG_HMAC_SECRET");
}
// RFC-3986 percent-encoding: keep A-Za-z0-9-._~
const enc = (s) =>
encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
// Every query param except the signature, INCLUDING key
const all = { ...params, key: apiKey };
// Canonical query: sorted enc(key)=enc(value) pairs joined with &
const pairs = [];
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
pairs.push(`${enc(k)}=${enc(vv)}`);
}
}
pairs.sort();
const canonical = pairs.join("&");
// Sign the exact bytes the server will verify
const signature = crypto
.createHmac("sha256", hmacSecret)
.update(`GET/api/v1/og\n${canonical}`)
.digest("hex");
// Build the URL from the same pairs so bytes match the canonical form
const url = new URL(baseUrl);
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
url.searchParams.append(Array.isArray(v) ? `${k}[]` : k, vv);
}
}
url.searchParams.set("s", signature);
return url.toString();
}
Step 3: Use in Your App
// app/blog/[slug]/page.jsx
import { generateSignedOgUrl } from "@/lib/og-image-signer";
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
const ogImageUrl = generateSignedOgUrl({
template: "blog",
title: post.title,
subtitle: post.excerpt,
});
return {
openGraph: {
images: [ogImageUrl],
},
};
}
Step 4: Test
The signer is an ES module (import/export), so use a dynamic import():
node -e "
import('./lib/og-image-signer.js').then(({ generateSignedOgUrl }) => {
console.log(generateSignedOgUrl({ title: 'Test' }));
});
"
Next.js App Router
Pattern 1: Metadata API (Recommended)
// app/blog/[slug]/page.tsx
import { generateSignedOgUrl } from "@/lib/og-image-signer";
import { getPost } from "@/lib/posts";
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
const ogImageUrl = generateSignedOgUrl({
template: "blog",
title: post.title,
subtitle: post.excerpt,
author: post.author.name,
// Optional: customize colors
theme: "indigo",
});
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: post.title,
},
],
type: "article",
},
twitter: {
card: "summary_large_image",
images: [ogImageUrl],
},
};
}
Pattern 2: API Route Proxy
Use this if you want to cache OG images or add custom logic.
// app/api/og/route.ts
import { NextRequest, NextResponse } from "next/server";
import { generateSignedOgUrl } from "@/lib/og-image-signer";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title");
const template = searchParams.get("template") || "default";
if (!title) {
return NextResponse.json(
{ error: "Missing title parameter" },
{ status: 400 },
);
}
// Generate signed URL
const ogImageUrl = generateSignedOgUrl({
template,
title,
});
// Option 1: Redirect to FastOG
return NextResponse.redirect(ogImageUrl);
// Option 2: Fetch and return image (for caching)
// const response = await fetch(ogImageUrl);
// const imageBuffer = await response.arrayBuffer();
// return new NextResponse(imageBuffer, {
// headers: {
// 'Content-Type': 'image/png',
// 'Cache-Control': 'public, max-age=31536000, immutable',
// },
// });
} catch (error) {
console.error("OG image generation failed:", error);
return NextResponse.json(
{ error: "Failed to generate OG image" },
{ status: 500 },
);
}
}
Usage:
// In your component
<meta property="og:image" content={`/api/og?title=${encodeURIComponent(post.title)}`} />
Pattern 3: Server Component Direct
// app/blog/[slug]/page.tsx
import { generateSignedOgUrl } from '@/lib/og-image-signer';
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
const ogImageUrl = generateSignedOgUrl({
template: 'blog',
title: post.title,
});
return (
<article>
<head>
<meta property="og:image" content={ogImageUrl} />
<meta property="og:title" content={post.title} />
</head>
{/* ... post content ... */}
</article>
);
}
Next.js Pages Router
// pages/blog/[slug].tsx
import { generateSignedOgUrl } from '@/lib/og-image-signer';
import { GetStaticProps, GetStaticPaths } from 'next';
interface BlogPostProps {
post: Post;
ogImageUrl: string;
}
export default function BlogPost({ post, ogImageUrl }: BlogPostProps) {
return (
<>
<Head>
<meta property="og:image" content={ogImageUrl} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
</Head>
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</article>
</>
);
}
export async function getStaticPaths() {
// Generate paths...
}
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
const ogImageUrl = generateSignedOgUrl({
template: 'blog',
title: post.title,
subtitle: post.excerpt,
});
return {
props: {
post,
ogImageUrl,
},
};
}
Express.js Backend
Setup
npm install express crypto
Implementation
// app.js
import express from "express";
import crypto from "crypto";
import { URL } from "url";
const app = express();
function generateSignedOgUrl(params, options = {}) {
const {
baseUrl = "https://fastog.com/api/v1/og",
apiKey = process.env.FASTOG_API_KEY,
hmacSecret = process.env.FASTOG_HMAC_SECRET,
} = options;
// RFC-3986 percent-encoding: keep A-Za-z0-9-._~
const enc = (s) =>
encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
// Every query param except the signature, INCLUDING key
const all = { ...params, key: apiKey };
// Canonical query: sorted enc(key)=enc(value) pairs joined with &
const pairs = [];
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
pairs.push(`${enc(k)}=${enc(vv)}`);
}
}
pairs.sort();
const canonical = pairs.join("&");
// Sign the exact bytes the server will verify
const signature = crypto
.createHmac("sha256", hmacSecret)
.update(`GET/api/v1/og\n${canonical}`)
.digest("hex");
// Build the URL from the same pairs so bytes match the canonical form
const url = new URL(baseUrl);
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
url.searchParams.append(Array.isArray(v) ? `${k}[]` : k, vv);
}
}
url.searchParams.set("s", signature);
return url.toString();
}
// Blog post route
app.get("/blog/:slug", async (req, res) => {
const post = await getPost(req.params.slug);
const ogImageUrl = generateSignedOgUrl({
template: "blog",
title: post.title,
subtitle: post.excerpt,
});
res.render("blog/post", {
post,
ogImageUrl,
});
});
// API endpoint for dynamic OG images
app.get("/api/og", (req, res) => {
const { title, template = "default" } = req.query;
if (!title) {
return res.status(400).json({ error: "Missing title" });
}
const ogImageUrl = generateSignedOgUrl({
template,
title,
});
res.json({ ogImageUrl });
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});
EJS Template
<!-- views/blog/post.ejs -->
<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="<%= ogImageUrl %>" />
<meta property="og:title" content="<%= post.title %>" />
<meta property="og:description" content="<%= post.excerpt %>" />
</head>
<body>
<h1><%= post.title %></h1>
</body>
</html>
Vanilla Node.js
// og-signer.js
const crypto = require("crypto");
const { URL } = require("url");
function generateSignedOgUrl(params, options = {}) {
const {
baseUrl = "https://fastog.com/api/v1/og",
apiKey = process.env.FASTOG_API_KEY,
hmacSecret = process.env.FASTOG_HMAC_SECRET,
} = options;
// RFC-3986 percent-encoding: keep A-Za-z0-9-._~
const enc = (s) =>
encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
// Every query param except the signature, INCLUDING key
const all = { ...params, key: apiKey };
// Canonical query: sorted enc(key)=enc(value) pairs joined with &
const pairs = [];
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
pairs.push(`${enc(k)}=${enc(vv)}`);
}
}
pairs.sort();
const canonical = pairs.join("&");
// Sign the exact bytes the server will verify
const signature = crypto
.createHmac("sha256", hmacSecret)
.update(`GET/api/v1/og\n${canonical}`)
.digest("hex");
// Build the URL from the same pairs so bytes match the canonical form
const url = new URL(baseUrl);
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
url.searchParams.append(Array.isArray(v) ? `${k}[]` : k, vv);
}
}
url.searchParams.set("s", signature);
return url.toString();
}
module.exports = { generateSignedOgUrl };
Usage:
// index.js
const { generateSignedOgUrl } = require("./og-signer");
const ogUrl = generateSignedOgUrl({
template: "blog",
title: "My Blog Post",
});
console.log("OG Image URL:", ogUrl);
Frontend Integration Patterns
Pattern 1: Static Site Generation (SSG)
Best for: Blog posts, product pages, content that doesn't change frequently.
// Generate at build time
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
openGraph: {
images: [generateSignedOgUrl({ title: post.title })],
},
};
}
Pattern 2: Server-Side Rendering (SSR)
Best for: Dynamic content, user-specific pages.
export default async function Page({ params }) {
const data = await fetchData(params.id);
const ogImageUrl = generateSignedOgUrl({ title: data.title });
return (
<>
<Head>
<meta property="og:image" content={ogImageUrl} />
</Head>
<Content data={data} />
</>
);
}
Pattern 3: Client-Side Fallback
When server-side generation isn't possible.
'use client';
import { useEffect, useState } from 'react';
export default function ShareButtons({ post }) {
const [ogImageUrl, setOgImageUrl] = useState<string | null>(null);
useEffect(() => {
// Fetch from your API that generates signed URLs
fetch(`/api/og?title=${encodeURIComponent(post.title)}`)
.then(res => res.json())
.then(data => setOgImageUrl(data.ogImageUrl));
}, [post.title]);
const shareUrl = ogImageUrl || '/fallback-og.png';
return (
<div>
<button onClick={() => shareToTwitter(shareUrl)}>
Share on Twitter
</button>
</div>
);
}
⚠️ Warning: Client-side generation means social crawlers won't see the dynamic OG image. Use SSR/SSG when possible.
Testing & Debugging
Unit Tests
// __tests__/og-image-signer.test.js
import { generateSignedOgUrl } from "../lib/og-image-signer";
describe("generateSignedOgUrl", () => {
const mockParams = {
title: "Test Post",
template: "blog",
};
const mockOptions = {
apiKey: "sk_test1234567890",
hmacSecret: "a".repeat(64),
};
it("generates a valid URL", () => {
const url = generateSignedOgUrl(mockParams, mockOptions);
expect(url).toMatch(/^https:\/\/fastog\.com\/api\/v1\/og\?/);
expect(url).toContain("key=sk_test");
expect(url).toContain("s=");
});
it("includes all params", () => {
const url = generateSignedOgUrl(mockParams, mockOptions);
const parsed = new URL(url);
expect(parsed.searchParams.get("title")).toBe("Test Post");
expect(parsed.searchParams.get("template")).toBe("blog");
});
it("throws without API key", () => {
expect(() => generateSignedOgUrl(mockParams, {})).toThrow();
});
});
Manual Testing
# 1. Generate URL (ES module — use dynamic import)
node -e "
import('./lib/og-image-signer.js').then(({ generateSignedOgUrl }) => {
const url = generateSignedOgUrl({ title: 'Test' });
console.log(url);
});
"
# 2. Test with curl
curl "https://fastog.com/api/v1/og?title=Test&key=sk_quick_123abc&s=<sig>"
# 3. Check response headers
curl -I "https://fastog.com/api/v1/og?title=Test&key=sk_quick_123abc&s=<sig>"
Debug Mode
function generateSignedOgUrl(params, options = {}) {
const debug = options.debug || process.env.DEBUG_OG === "true";
// ... existing code ...
if (debug) {
console.log("OG URL Debug:", {
baseUrl,
canonical,
apiKey,
signature,
finalUrl: url.toString(),
});
}
return url.toString();
}
Common Pitfalls
1. Exposing HMAC Secret in Client Code
❌ Wrong:
// In client component
const ogUrl = generateSignedOgUrl(params, {
hmacSecret: process.env.NEXT_PUBLIC_HMAC_SECRET,
});
✅ Correct:
// In server component or API route
export async function generateMetadata() {
const ogUrl = generateSignedOgUrl(params);
// ...
}
2. Not Handling URL Encoding
❌ Wrong:
const url = `${baseUrl}?title=${params.title}&key=${apiKey}`;
✅ Correct:
const url = new URL(baseUrl);
url.searchParams.set("title", params.title);
url.searchParams.set("key", apiKey);
3. Signing Different Bytes Than You Send
The signature covers GET/api/v1/og plus a newline plus the canonical query — the sorted enc(key)=enc(value) pairs of every param except s, including key. Build the final URL from those same encoded pairs, or the server returns 401 invalid_signature.
Environment Variables
# .env.local
FASTOG_API_KEY=sk_your_full_api_key_here
FASTOG_HMAC_SECRET=your_64_char_hex_secret_here
FASTOG_BASE_URL=https://fastog.com/api/v1/og
// lib/env.ts
import { z } from "zod";
const envSchema = z.object({
FASTOG_API_KEY: z.string().startsWith("sk_"),
FASTOG_HMAC_SECRET: z.string().length(64),
FASTOG_BASE_URL: z
.string()
.url()
.optional()
.default("https://fastog.com/api/v1/og"),
});
export const env = envSchema.parse(process.env);
Next Steps
Full API Reference
Related
Try it free — no signup required
Preview and test dynamic OG images in seconds.
Open the Free OG Image Tester