OG cards — @sonordev/site-kit/og
Social cards, rendered at build time by headless Chrome. Real CSS, real webfonts, no Satori subset, nothing at runtime.
npx sonor-setup ogThat renders the site card to public/og.png and one card per route.
The one rule that will bite you
Config metadata beats the file convention. A route whose generateMetadata
returns openGraph.images overrides its own opengraph-image file.
So a site using per-page cards must declare no images anywhere:
// ✅ root layout — no images array
openGraph: { type: 'website', locale: 'en_US', siteName: 'Acme' },
twitter: { card: 'summary_large_image' },// ❌ this silently disables EVERY per-page card
openGraph: { images: ['/og.png'] },That includes seo_pages.managed_og_image in Sonor. site-kit serves it into
openGraph.images for every managed page, so setting it suppresses that page's
card from the dashboard, with nothing in the repo to explain why. sonor-setup og reads it for every route while it titles the cards and names the pages that
set one; sonor-setup doctor --online does the same. Offline, the doctor says
nothing about it (it used to warn on every per-page site, set or not).
The one image a page may declare is its own per-URL card or a code card on
a trailingSlash site (both below). Those replace the route's card on purpose.
This was verified against real builds, both ways: with images declared, all 32 routes on a real site served the same card and every generated page card was inert; removing the declaration made each route serve its own.
A second surprise from the same investigation: file metadata does not cascade
to child segments. A card at services/ is not inherited by
services/[city]. Dynamic segments get their own card (one file covers every
param), which the generator does automatically.
sonor-setup og and sonor-setup doctor both check this through one shared
rule (og/wiring.ts), so the two never disagree. The rule reads the site's
code through
shared/source-graph.ts, which follows imports into lib/ helpers and monorepo
workspace packages, so twitter.card set in a metadata helper counts. It used
to read the root layout alone.
Writing the card
og.config.ts at the site root owns the theme. Sonor's brand data is only a
zero-config seed for sites that have no config yet.
import { defineOgCard } from '@sonordev/site-kit/og'
export default defineOgCard({
theme: { bg: '#0F172A', text: '#F8FAFC', accent: '#F59E0B', surface: '#1E293B' },
fonts: [{ family: 'Fraunces', weights: [800] }],
logo: '/logo-white.svg',
layout: 'split',
photo: { src: '/crew.jpg' },
content: {
kicker: 'Remodeling · Springfield',
title: 'Built by\nneighbors',
subtitle: 'Designed, built and installed by one local crew.',
bar: ['Free estimate', 'example.com'],
},
})Layouts: plate-left (logo plate + copy), centered, banner (copy only),
split (copy left, photo right). In split a configured logo renders as a
small mark above the kicker.
Copy is fitted, not guessed
The title opens at 104px and the renderer steps it down until it fits both axes, stopping at a 56px legibility floor — below that a headline stops reading at the ~300px thumbnail width platforms actually show.
If copy still doesn't fit at the floor, the command fails and names the element and the overflow in pixels. That's deliberate: otherwise a card can ship with the kicker off-canvas and the subtitle buried under the bottom bar while the CLI prints a tick.
Rules of thumb: about 10 uppercase characters per title line, and about 29 for
the kicker. \n in a title is a hard break, so choose the wrap yourself rather
than leaving it to the box.
Per-page cards
Every static route gets a card, plus one per dynamic segment. Copy comes from
Sonor's managed title and description when SONOR_API_KEY is set, so a card and
its search result say the same thing; otherwise the route path is titled.
Everything else is inherited from the site card, so the set reads as one family.
Hand-write the few that deserve it:
cards: {
'/free-estimate': {
content: { kicker: 'Free estimate', title: 'Know the\ncost first' },
photo: { src: '/lp/hero.jpg' },
},
},Keyed by route path or slug (services-garage), layered over the derived card,
so an entry only states what differs.
Managed titles carry the brand for the SERP (About Us | Acme); the card drops
it. It also drops the two broken suffixes managed titles turn up with: a dangling
separator with no brand after it (About Us |) and a domain after a comma
(Privacy Policy, example.com). A hyphen inside a word is never a
separator (Walk-In Showers stays whole).
Dynamic routes are keyed by pattern, one * per level
A dynamic segment has no single URL, so its card is keyed by the route pattern:
services/[slug] is /services/*, and services/[slug]/[metro] is
/services/*/*. Slug form is services-any and services-any-any.
Depth matters because a card file lives beside each page.tsx, so those two
directories are two different cards. They are also the one place the generator
runs out of facts: neither pattern has managed metadata of its own, so both
derive their copy from the nearest static ancestor (/services) and come out
identical. If the deeper route deserves its own words, write them:
cards: {
'/services/*': { content: { title: 'What we do' } },
'/services/*/*': { content: { kicker: 'Service areas', title: 'Near you' } },
},The generator keys each depth separately, so a /services/*/* entry reaches
the service-by-metro pages and nothing else. A cards key that matches no
rendered route is reported by sonor-setup og (og.cards, a warning), since
otherwise it would match nothing, silently.
Writing a nested pattern in a comment.
/services/*/*contains*/, which closes a/* */block comment early. In TypeScript put it in a string, a//line comment, or spell the depth out in prose. It's an easy slip in exactly the comment that explains the pattern.
Static routes under a dynamic segment get a card too
properties/[slug]/about is a static route under a dynamic one. It gets its
own card, keyed /properties/*/about and titled from its own name ("About",
kicker "properties"). The generator used to stop at the first dynamic segment,
which left these pages with no og:image at all.
Per-URL cards: one per floor plan, one per community
A static file in a dynamic segment covers every param, so /floor-plans/* is
one card for every plan. When each page deserves its own, key cards by the
URL itself. sonor-setup og loads og.config.ts with plain Node, so it can
import the site's data only through a relative path with the .ts extension
and no @/ aliases; a short list inline is often simpler:
// og.config.ts
import { defineOgCard } from '@sonordev/site-kit/og'
const floorPlans = [
{ slug: '1-bedroom', name: '1 Bedroom', image: '/living-room.webp' },
{ slug: '2-bedroom', name: '2 Bedroom', image: '/kitchen.webp' },
]
export default defineOgCard({
// ...theme, content
cards: {
'/floor-plans/*': { content: { title: 'Floor\nplans' } }, // the fallback
...Object.fromEntries(
floorPlans.map(plan => [
`/floor-plans/${plan.slug}`,
{ content: { kicker: 'Floor plan', title: plan.name }, photo: { src: plan.image } },
]),
),
},
})sonor-setup og renders each to public/_og/<url>.jpg (the kit owns that
directory and clears it every run), and the page declares its own:
// app/floor-plans/[slug]/page.tsx
import { paramCardImage } from '@sonordev/site-kit/og'
import ogConfig from '../../../og.config'
export async function generateMetadata({ params }) {
const { slug } = await params
const card = paramCardImage(`/floor-plans/${slug}`, { config: ogConfig })
return {
openGraph: { ...(card && { images: [card] }) },
twitter: { card: 'summary_large_image', ...(card && { images: [card.url] }) },
}
}With config, a page with no entry gets undefined and keeps the segment card.
The URL ends in .jpg, so a trailingSlash: true site never redirects it.
Per-URL cards need sharp (they have to be .jpg); without it the command
fails those cards and says so. Their copy comes from Sonor's managed title for
that URL, like any route card. A per-URL key matches a pattern one segment per
*, so /properties/maple-court/about falls under /properties/*/about.
Cards are re-encoded to JPEG when sharp resolves — on a real 18-card site that
was 5.6 MB → 1.4 MB. Without sharp they stay PNG, which is correct, just heavier.
--no-pages renders only the site card.
When cards go stale
Cards are build-time artifacts of the copy at generation time. If managed titles
change in Sonor, re-run sonor-setup og — nothing re-renders them automatically.
Worth adding to the same routine as a content pass.
Per-entity cards (tier 2)
For cards that must vary per record and can't wait for a rebuild — one per
article published from Sonor, per event — @sonordev/site-kit/og/route
renders on demand with next/og. Use the metadata file convention:
// app/article/[slug]/opengraph-image.tsx
import { createOgImage } from '@sonordev/site-kit/og/route'
import { getArticle } from '@sonordev/site-kit/articles/server'
import ogConfig from '../../../og.config'
export { size, contentType } from '@sonordev/site-kit/og/route'
export const alt = 'From the Acme article'
export default createOgImage<{ slug: string }>(async ({ slug }) => {
const post = await getArticle(slug)
if (!post) return null // 404
return {
theme: ogConfig.theme,
kicker: 'From the publication',
title: post.title,
photoUrl: post.featured_image, // must be absolute
bar: 'example.com',
}
})Next calls it with { params } and wires og:image for the post by itself, and
sonor-setup og sees the file and leaves the folder alone. The post page passes
images: false to generateArticleMetadata, or the featured image beats the
card (the doctor flags a page that doesn't).
createOgImageRoute is the same card as a route handler, GET(req, { params }),
for a card at a URL of your own. Next doesn't wire a route handler into
metadata. It used to be the only shape, so sites wrote an adapter to use it
from opengraph-image.tsx; delete those for createOgImage.
The runtime card fits its title the way the build-time card does, from an
estimate since Satori can't measure: 104px down to the 56px floor. When the
title can't fit beside the photo even at the floor, the photo goes and the
title gets the full width. No more hand-picked "drop the photo past 40
characters". The bottom bar's text defaults to whichever of surface, bg and
text reads on the accent (WCAG 3:1 for large text), in that order; set
theme.barText to choose. It used to be text, which put navy on crimson. The
build-time card uses the same rule, and keeps surface wherever it already
read.
Satori's constraints still apply: a flexbox-only CSS subset, no external
stylesheets, images as absolute URLs or data URIs (a relative photoUrl is
dropped), and fonts supplied as ArrayBuffers. Reach for it only when the cards
can't be rendered at build time; per-URL cards in og.config.ts cover
everything generateStaticParams can list.
Code cards on a trailingSlash: true site
Next serves a code card at <page>/opengraph-image, with no extension, and
trailingSlash: true 308-redirects that URL, so every share preview starts
with a redirect. Declare the slashed URL from the page instead:
import { codeCardImage } from '@sonordev/site-kit/og'
openGraph: { images: [codeCardImage(`/article/${slug}`)] }, // /article/x/opengraph-image/trailingSlash defaults to the site's next.config. sonor-setup og and the
doctor flag a code card on a trailingSlash site whose page doesn't. A code card
at opengraph-image/route.tsx (a route handler folder) is recognised too, so
the generator no longer writes an opengraph-image.jpg beside it, which Next
refused to build.
Reminder
Facebook caches aggressively. After deploying a new card, re-scrape at https://developers.facebook.com/tools/debug/.