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, ContentOG React 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() — wraps next/og's ImageResponse with your theme's fonts and dimensions.
  • loadOGImage() — fetches any remote image (including WEBP), resizes via sharp, and returns a Satori-friendly data: URL. Cached per-URL at module scope.
  • Metadata builders (buildSiteOGMetadata, buildContentOGMetadata) that emit the right openGraph + twitter Metadata 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

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 .ttf files. Fontsource mirrors every Google Font at https://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.
  • Colorsaccent drives the top bar and hostname label; border draws 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.

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.