Imágenes OG en JavaScript
Genera og:image en JavaScript con la API de FastOG — con una suscripción a la API dinámica (crea tu clave de API en el panel de control), basada en suscripción y protegida con HMAC. Esta guía te muestra cómo crear una utilidad de firma (signer) para URLs de imágenes OG firmadas con HMAC y renderizar imágenes Open Graph dinámicas sobre la marcha. Prueba el Probador de Imágenes OG Gratuito para previsualizar antes de publicar.
Índice
- Inicio rápido (5 minutos)
- Next.js App Router
- Next.js Pages Router
- Backend con Express.js
- Node.js puro
- Patrones de integración en el frontend
- Pruebas y depuración
- Errores comunes
Inicio rápido
Objetivo: Generar una URL de imagen OG firmada en 5 minutos.
Paso 1: Instalar dependencias
npm install crypto # Built-in, no install needed
Paso 2: Crear la utilidad de firma
// 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();
}
Paso 3: Úsalo en tu aplicación
// 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],
},
};
}
Paso 4: Prueba
El firmante es un módulo ES (import/export), así que usa un import() dinámico:
node -e "
import('./lib/og-image-signer.js').then(({ generateSignedOgUrl }) => {
console.log(generateSignedOgUrl({ title: 'Test' }));
});
"
Next.js App Router
Patrón 1: API de metadatos (recomendado)
// 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],
},
};
}
Patrón 2: Proxy de ruta de API
Usa esto si quieres almacenar en caché las imágenes OG o añadir lógica personalizada.
// 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 },
);
}
}
Uso:
// In your component
<meta property="og:image" content={`/api/og?title=${encodeURIComponent(post.title)}`} />
Patrón 3: Componente de servidor directo
// 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,
},
};
}
Backend con Express.js
Configuración
npm install express crypto
Implementación
// 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");
});
Plantilla EJS
<!-- 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 };
Uso:
// index.js
const { generateSignedOgUrl } = require("./og-signer");
const ogUrl = generateSignedOgUrl({
template: "blog",
title: "My Blog Post",
});
console.log("OG Image URL:", ogUrl);
Patrones de Integración en el Frontend
Patrón 1: Generación de Sitios Estáticos (SSG)
Ideal para: Entradas de blog, páginas de producto, contenido que no cambia con frecuencia.
// 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 })],
},
};
}
Patrón 2: Renderizado del Lado del Servidor (SSR)
Ideal para: Contenido dinámico, páginas específicas de usuario.
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} />
</>
);
}
Patrón 3: Respaldo del Lado del Cliente
Cuando la generación del lado del servidor no es posible.
'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>
);
}
⚠️ Advertencia: La generación del lado del cliente significa que los rastreadores sociales no verán la imagen OG dinámica. Usa SSR/SSG cuando sea posible.
Pruebas y depuración
Pruebas unitarias
// __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();
});
});
Pruebas manuales
# 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>"
Modo de depuración
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();
}
Errores comunes
1. Exponer el secreto HMAC en el código del cliente
❌ Incorrecto:
// In client component
const ogUrl = generateSignedOgUrl(params, {
hmacSecret: process.env.NEXT_PUBLIC_HMAC_SECRET,
});
✅ Correcto:
// In server component or API route
export async function generateMetadata() {
const ogUrl = generateSignedOgUrl(params);
// ...
}
2. No gestionar la codificación de URL
❌ Incorrecto:
const url = `${baseUrl}?title=${params.title}&key=${apiKey}`;
✅ Correcto:
const url = new URL(baseUrl);
url.searchParams.set("title", params.title);
url.searchParams.set("key", apiKey);
3. Firmar bytes distintos de los que envías
La firma cubre GET/api/v1/og más un salto de línea más la consulta canónica: los pares enc(key)=enc(value) ordenados de cada parámetro excepto s, incluido key. Construye la URL final a partir de esos mismos pares codificados, o el servidor devolverá 401 invalid_signature.
Variables de entorno
# .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);