Backend opengraph-images
Create dynamic OpenGraph images for social media sharing using Next.js ImageResponse API. Use when adding OG images to new pages, updating existing OG images, or implementing page-specific social previews.
install
source · Clone the upstream repo
git clone https://github.com/sgcarstrends/sgcarstrends
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/sgcarstrends/sgcarstrends "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/opengraph-images" ~/.claude/skills/sgcarstrends-backend-opengraph-images && rm -rf "$T"
manifest:
.claude/skills/opengraph-images/skill.mdsource content
OpenGraph Images Skill
This skill helps you create consistent, branded OpenGraph images for social media sharing across the web application.
When to Use This Skill
- Creating dynamic OG images for new pages
- Updating existing OG image content/styling
- Implementing page-specific social previews
- Adding custom fonts to OG images
- Debugging OG image rendering issues
Key Files
| File | Purpose |
|---|---|
| Static homepage OG image |
| Dynamic About page OG image |
| Dynamic blog post OG images |
| Geist font files for OG images |
Standard Configuration
Required Exports
import { ImageResponse } from "next/og"; // Alt text for accessibility export const alt = "Page Title - SG Cars Trends"; // Standard OG image dimensions export const size = { width: 1200, height: 630, }; // Image format export const contentType = "image/png";
Font Loading
Load Geist fonts from
assets/fonts/ for consistent typography:
import { readFile } from "node:fs/promises"; import { join } from "node:path"; export default async function Image() { const [geistRegular, geistSemiBold, geistBold] = await Promise.all([ readFile(join(process.cwd(), "assets/fonts/Geist-Regular.ttf")), readFile(join(process.cwd(), "assets/fonts/Geist-SemiBold.ttf")), readFile(join(process.cwd(), "assets/fonts/Geist-Bold.ttf")), ]); return new ImageResponse( // JSX content <div>...</div>, { ...size, fonts: [ { name: "Geist", data: geistRegular, style: "normal", weight: 400 }, { name: "Geist", data: geistSemiBold, style: "normal", weight: 500 }, { name: "Geist", data: geistBold, style: "normal", weight: 700 }, ], }, ); }
Design Structure
Standard Layout Pattern
OG images follow a consistent three-part structure:
┌────────────────────────────────────────────┐ │ [Eyebrow Chip] (page context indicator) │ │ │ │ Main Headline │ │ With Gradient Text │ │ │ │ Subheadline description text that │ │ provides additional context │ └────────────────────────────────────────────┘
Eyebrow Chip
Small pill-shaped indicator that signals page type/context:
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 16px", backgroundColor: "rgba(37, 99, 235, 0.05)", border: "1px solid rgba(37, 99, 235, 0.2)", borderRadius: 9999, marginBottom: 32, }} > <div style={{ width: 8, height: 8, borderRadius: "50%", backgroundColor: "#2563eb", }} /> <span style={{ fontSize: 16, fontWeight: 500, color: "rgba(10, 10, 10, 0.9)", letterSpacing: "0.025em", }} > Behind the Data </span> </div>
Eyebrow Text Guidelines:
| Page Type | Eyebrow Text |
|---|---|
| Homepage | Singapore Car Market Data |
| About | Behind the Data |
| Blog Post | Blog / Analysis / Insights |
| COE | COE Bidding Results |
| Cars | Vehicle Registrations |
Main Headline
Two-line headline with gradient text on the second line:
<div style={{ display: "flex", flexDirection: "column", fontSize: 64, fontWeight: 700, color: "#0a0a0a", lineHeight: 1.1, letterSpacing: "-0.025em", marginBottom: 24, }} > <span>First Line of</span> <span style={{ backgroundImage: "linear-gradient(to right, #2563eb, rgba(37, 99, 235, 0.7))", backgroundClip: "text", color: "transparent", }} > Gradient Headline </span> </div>
Subheadline
Supporting description text:
<div style={{ display: "flex", fontSize: 24, color: "rgba(10, 10, 10, 0.7)", lineHeight: 1.5, maxWidth: 700, fontWeight: 400, }} > Description text that provides additional context about the page content. </div>
Colour Palette
| Element | Colour | Value |
|---|---|---|
| Background | Light gray | |
| Primary text | Near black | |
| Secondary text | Muted | |
| Primary blue | Brand | |
| Gradient end | Lighter blue | |
| Chip background | Tinted | |
| Chip border | Subtle | |
Static vs Dynamic OG Images
Use Static PNG When:
- Content never changes (homepage)
- No page-specific data needed
- Maximum performance required
src/app/opengraph-image.png # Just place the file
Use Dynamic TSX When:
- Content varies by page/route (blog posts, about)
- Need custom fonts
- Dynamic data from database
// src/app/[route]/opengraph-image.tsx export default async function Image({ params }) { // Fetch data, generate dynamic content }
Dynamic Route OG Images
For routes with dynamic segments (e.g., blog posts):
import { getAllPosts, getPostBySlug } from "@web/queries/posts"; import { ImageResponse } from "next/og"; interface Props { params: Promise<{ slug: string }>; } export const size = { width: 1200, height: 630 }; export const dynamic = "force-static"; export async function generateStaticParams() { const posts = await getAllPosts(); return posts.map((post) => ({ slug: post.slug })); } const Image = async ({ params }: Props) => { const { slug } = await params; const post = await getPostBySlug(slug); if (!post) { return new Response("Not found", { status: 404 }); } return new ImageResponse( <div style={{ /* ... */ }}> {post.title} </div>, { ...size, headers: { "Cache-Control": "public, max-age=31536000, s-maxage=31536000, immutable", }, }, ); }; export default Image;
Caching Headers
For static/infrequently changing OG images:
return new ImageResponse(content, { ...size, headers: { "Cache-Control": "public, max-age=31536000, s-maxage=31536000, immutable", }, });
Important Constraints
ImageResponse JSX Limitations
The
ImageResponse API uses Satori for rendering, which has constraints:
- No CSS classes - Use inline
objects onlystyle - Limited CSS properties - Flexbox works, Grid doesn't
- No external images - Must be base64 or absolute URLs
- Font files required - Must load
files explicitly.ttf - No React hooks - Server-side only
Common Style Gotchas
// ❌ Won't work <div className="flex gap-4"> // ✅ Use inline styles <div style={{ display: "flex", gap: 16 }}> // ❌ CSS Grid not supported <div style={{ display: "grid" }}> // ✅ Use Flexbox <div style={{ display: "flex", flexWrap: "wrap" }}>
Testing OG Images
- Local development: Visit
directlyhttp://localhost:3000/about/opengraph-image - Social debuggers:
- OG Preview tools: opengraph.xyz
File Organization
src/app/ ├── opengraph-image.png # Static homepage OG ├── about/ │ └── opengraph-image.tsx # Dynamic About OG ├── blog/ │ └── [slug]/ │ └── opengraph-image.tsx # Dynamic blog post OG ├── cars/ │ └── opengraph-image.tsx # Dynamic cars section OG └── coe/ └── opengraph-image.tsx # Dynamic COE section OG
Validation Checklist
When creating/updating OG images:
- Exports
,alt
, andsizecontentType - Uses standard 1200x630 dimensions
- Follows three-part structure (eyebrow, headline, subheadline)
- Uses brand colours from palette
- Loads Geist fonts for custom typography
- Uses inline styles only (no CSS classes)
- Eyebrow text clearly indicates page context
- Headline is concise and fits on two lines max
- Subheadline provides useful context
- Tested in social media debuggers
Related Files
- Web app conventionsapps/web/CLAUDE.md
- Geist font filesapps/web/assets/fonts/
- Site title and URL constantsapps/web/src/config/index.ts