docs
    site-kit: OG cards
    v7.2.0.md

    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 og

    That 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/.