OG images
Generate branded, theme-driven OpenGraph images for every page — marketing, blog, docs — with the Inventra OG plugin. Covers install, theme config, route setup, and the blog featured-image composite.
OpenGraph images are the 1200×630 previews that show up when someone shares your page on LinkedIn, X, WhatsApp, Slack, iMessage. Done right, they boost click-through and make every link look like it came from a real brand. Done wrong (or not at all), your links look generic and get ignored.
Inventra ships an OG plugin that handles the hard parts — Satori rendering, font loading, image compositing, caching — so you just describe your brand once and every page gets a consistent preview for free.
What you get
- A reusable layout system (
SiteOG,ContentOGReact templates) that renders consistent 1200×630 previews. - A theme (
OGTheme) that carries your colors, fonts, logo, hostname, and footer tagline. One source of truth. createOGResponse()— wrapsnext/og'sImageResponsewith your theme's fonts and dimensions.loadOGImage()— fetches any remote image (including WEBP), resizes viasharp, and returns a Satori-friendlydata:URL. Cached per-URL at module scope.- Metadata builders (
buildSiteOGMetadata,buildContentOGMetadata) that emit the rightopenGraph+twitterMetadata fragments pointing at your OG routes.
The plugin ships as inventra/og — a sub-export of the Inventra SDK. npm install inventra and import — no files to copy. sharp and date-fns are bundled as direct deps, so there's no extra peer install step.
Prerequisites
- Next.js 15+ with the App Router and
next/og inventra >= 0.2.0(brings insharp+date-fnsautomatically)- An
OGThemefor your brand (see below) - A centralised Inventra config — see SDK installation and Production setup → Centralise your Inventra config
This guide focuses on the OG plugin; the broader SEO integration (sitemap, llms.txt, JSON-LD, metadata) is covered in SEO setup.
1. Define your theme
Every OG image is composed against an OGTheme. The recommended home is alongside the rest of your Inventra config in packages/plugins/config.ts (see Production setup → Centralise your Inventra config) — that's where the site object and the Inventra client already live. Every OG route then imports the same myOGTheme:
// packages/plugins/config.ts (add to the file alongside `inventra`, `site`, `canonical`)
import type { OGTheme } from 'inventra/og';
import { site } from '@/packages/plugins/config';
export const myOGTheme: OGTheme = {
colors: {
background: '#0B0B0B',
text: '#FFFFFF',
mutedText: '#A3A3A3',
accent: '#F59E0B',
border: '#2A2A2A'
},
fonts: {
regular: {
name: 'Geist',
url: 'https://cdn.jsdelivr.net/fontsource/fonts/geist@latest/latin-400-normal.ttf',
weight: 400
},
bold: {
name: 'GeistBold',
url: 'https://cdn.jsdelivr.net/fontsource/fonts/geist@latest/latin-700-normal.ttf',
weight: 700
},
// Optional: a display font for titles. Falls back to `bold` when absent.
heading: {
name: 'SourceSerif4',
url: 'https://cdn.jsdelivr.net/fontsource/fonts/source-serif-4@latest/latin-700-normal.ttf',
weight: 700
}
},
brand: {
hostname: new URL(site.url).host,
footerTagline: 'Acme · AI-powered everything',
// Optional: raster logo (PNG/JPEG) rendered above the eyebrow
logo: {
url: `${site.url}/images/logo-light.png`,
width: 140,
height: 34
}
}
};Notes
- Font URLs must point at raw
.ttffiles. Fontsource mirrors every Google Font athttps://cdn.jsdelivr.net/fontsource/fonts/<slug>@latest/latin-<weight>-normal.ttf. - Logo must be a raster format (PNG or JPEG). Satori doesn't render external SVGs reliably. For dark OG backgrounds, use the light/white variant of your logo.
- Colors —
accentdrives the top bar and hostname label;borderdraws the footer chip outline.
2. Static site pages route
One catch-all route serves every marketing page. Each page's og:image points at /api/og/site/<slug>.
// app/api/og/[context]/[page]/route.tsx
import { myOGTheme } from '@/packages/plugins/config';
import {
createOGResponse,
loadOGImage,
SiteOG
} from 'inventra/og';
export const runtime = 'nodejs';
const SITE_COPY: Record<string, { title: string; description: string }> = {
home: {
title: 'Your headline goes here.',
description: 'Your subheadline explains what you do in one line.'
},
about: {
title: 'Who we are.',
description: 'A short story.'
}
// ...one entry per page
};
const COPY: Record<string, Record<string, { title: string; description: string }>> = {
site: SITE_COPY
};
export async function GET(
_req: Request,
{ params }: { params: Promise<{ context: string; page: string }> }
) {
const { context, page } = await params;
const copy = COPY[context]?.[page];
if (!copy) return new Response('Not found', { status: 404 });
const logoDataUrl = await loadOGImage(myOGTheme.brand.logo?.url, {
maxWidth: 320
});
return createOGResponse(
<SiteOG
theme={myOGTheme}
title={copy.title}
description={copy.description}
logoDataUrl={logoDataUrl}
/>,
{ theme: myOGTheme }
);
}Why loadOGImage for the logo? Satori can silently fail to load remote images (particularly WEBP or cross-origin URLs). loadOGImage fetches the file, normalizes it via sharp, and returns a data: URL Satori always renders. Results are cached per-URL at module scope, the fetch happens once per cold start.
3. Blog posts route (with featured image composite)
Blog posts get a richer template that composites the post's featured image into the branded frame. Posts without a featured image render a text-only variant automatically.
// app/api/og/blog/[slug]/route.tsx
import { withISR } from 'inventra/next';
import { inventra, myOGTheme } from '@/packages/plugins/config';
import {
ContentOG,
createOGResponse,
loadOGImage
} from 'inventra/og';
export const runtime = 'nodejs';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const content = await inventra.contents
.getBySlug(slug, withISR(60))
.catch(() => null);
if (!content) return new Response('Not found', { status: 404 });
const [imageDataUrl, logoDataUrl] = await Promise.all([
loadOGImage(content.featuredImageUrl),
loadOGImage(myOGTheme.brand.logo?.url, { maxWidth: 320 })
]);
return createOGResponse(
<ContentOG
theme={myOGTheme}
eyebrow="Acme · Blog"
title={content.title}
author={content.authorName}
publishedAt={content.publishedAt}
imageDataUrl={imageDataUrl}
logoDataUrl={logoDataUrl}
/>,
{
theme: myOGTheme,
headers: {
'Cache-Control':
'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800'
}
}
);
}ContentOG drops the featured image block when imageDataUrl is undefined, so both variants render automatically from the same template. No if/else in your route.
4. Wire the metadata on every page
The OG routes serve the image; your pages still need og:image meta tags pointing at them. Use the metadata builders so you never hard-code URLs.
Site pages:
// app/about/page.tsx
import type { Metadata } from 'next';
import { canonical, site } from '@/packages/plugins/config';
import { buildSiteOGMetadata } from 'inventra/og';
const title = 'About Acme';
const description = 'Our story.';
export const metadata: Metadata = {
title,
description,
alternates: { canonical: canonical('/about') },
...buildSiteOGMetadata({
page: 'about',
title,
description,
baseUrl: site.url,
path: '/about'
})
};Blog posts:
// app/blog/[slug]/page.tsx (inside generateMetadata)
import { buildContentOGMetadata } from 'inventra/og';
return {
title: post.title,
description,
alternates: { canonical: canonical(`/blog/${post.slug}`) },
...buildContentOGMetadata({
content: post,
description,
baseUrl: site.url,
path: `/blog/${post.slug}`
})
};Custom route prefix: buildSiteOGMetadata accepts a routePrefix option (defaults to 'site') if you serve OGs from a different catch-all — the built URL becomes ${baseUrl}/api/og/${routePrefix}/${page}. Useful when different content types have their own OG route.
5. Verify
Start your dev server and hit each OG URL directly:
| URL | Expected |
|---|---|
/api/og/site/home |
1200×630 PNG with your logo, headline, subhead, hostname, tagline |
/api/og/blog/<slug> (post with featuredImageUrl) |
Composite: branded frame + featured image + title + author + date |
/api/og/blog/<slug> (post without a cover) |
Branded frame, text-only, title centered |
| Any page: view source | <meta property="og:image" content="…/api/og/…"> resolves and loads |
Troubleshooting
| Symptom | Likely cause |
|---|---|
| OG renders frame but no featured image | Source format Satori can't decode (e.g. WEBP). Pipe it through loadOGImage instead of passing the raw URL to <img>. |
| Empty reply / server crash on blog OG | Probably inlining a huge blob as base64 without resizing. loadOGImage resizes to maxWidth: 720 by default — use it. |
| Logo missing | Wrong variant for the bg — use your light-on-dark logo for dark OGs. Also confirm the URL is reachable from the server that renders the OG route. |
| Font doesn't change | Template caches fonts per URL. Change the name or bump weight to force a refetch, or restart the dev server. |
| Text gets cut off | Title uses heading when defined, else bold. Serif fonts need more line-height — tune fontSize/lineHeight in SiteOG/ContentOG. |