Skip to content

OG Cards

@plumix/plugin-og renders the image a link unfurls into on X, LinkedIn or Slack, and serves it from your own site. Installing it and configuring nothing serves a card per published entry, composited from a bundled template — its title over your site’s name.

The plugin exports a factory named og. It adds no database tables, so installing it needs no migration, and it takes four options — renderer, fonts, preview and palette — each with a default. renderer and fonts are read together: a renderer declares which font formats it parses, and the plugin hands it no others.

A route at /_plumix/og/card/<target>/<digest>.<ext> renders one page’s card on the first request, writes it to your storage bucket and reads it back on every request after. A seo.ogImage handler puts that URL in the page’s head, alongside og:image:width and og:image:height so a scraper can lay the preview out before it fetches a byte. A theme’s ogCards array replaces the bundled template with cards of your own, declared beside its templates and styled in its own design tokens. The bundled template reads three of those tokens too, so a card looks like the rest of the site before you have declared a card at all.

The URL is content-addressed. Its digest covers everything the card read — the entry’s updatedAt, the card’s own source, the font set, the theme’s tokens — and the bytes are served immutable. It is also the only thing that makes X, Facebook and LinkedIn refetch an image they already hold: a purge reaches Cloudflare and stops there, while an edit publishes a different URL.

The route answers a request carrying a matching If-None-Match with 304. With a cdn: slot configured, the edge also caches a card. An edit never purges it: the edited entry’s card lands on a new URL, and the old one stays stored until it expires. Drop the digest, as in /_plumix/og/card/entry/12.png, and the route redirects to whichever render is current, which is how you open a card by hand. A plumix sync that replaced the site’s content deletes every stored card, under og/ in the bucket, so each renders again from the new content on its next request.

<target> names the page a card is about — entry/12, term/3, archive/post, author/7, date/2026-03, front-page — and each of those six gets a card from the bundled template with nothing configured. See which page kinds have a card for when each one is served.

A registerArchiveType archive gets a card too, once a card.forArchiveType(name) rule names it: see plugin archives.

Install the package with whichever package manager the project uses:

Terminal window
pnpm add @plumix/plugin-og @plumix/plugin-seo

Then:

  1. Configure a storage: slot, then add seo() and og() to the plugins array. The bucket is where a rendered card is kept; without one every request renders the card again. @plumix/plugin-seo owns the page head, so without it the card renders at its route but never reaches an og:image.

    import { plumix } from "plumix";
    import { auth } from "plumix/auth";
    import { og } from "@plumix/plugin-og";
    import { seo } from "@plumix/plugin-seo";
    import {
    cloudflare,
    cloudflareDeployOrigin,
    d1,
    r2,
    } from "@plumix/runtime-cloudflare";
    import { theme } from "./theme";
    export default plumix({
    runtime: cloudflare(),
    database: d1({ binding: "DB", session: "auto" }),
    storage: r2({ binding: "MEDIA" }),
    auth: auth({
    passkey: {
    rpName: "Recipes",
    ...cloudflareDeployOrigin({
    workerName: "recipes",
    accountSubdomain: "your-account",
    localOrigin: "http://localhost:5173",
    }),
    },
    }),
    plugins: [seo(), og()],
    theme,
    });
  2. Restart the dev server.

    Terminal window
    pnpm dev
  3. Open a published entry that has no featured image and read its head. It carries an og:image pointing at /_plumix/og/card/entry/<id>/<digest>.png. Open that URL and the card is there — the entry’s title over the site’s name, 1200x630. An entry that does have a photo shares the photo instead, which is the next section.

create-plumix-app writes all of that — the import, the registration, the storage: slot and the bucket binding behind it — for a site scaffolded with --plugins og. Nothing else is required: a card renders with the engine’s own font face, and the imageDelivery: slot below sharpens the featured-image path without any card needing it.

Four links resolve one og:image, in this order:

  1. The entry’s ogImage image role. An author who picked a share image gets it, untouched.
  2. Its featured role — the entry’s own photo — cropped to the card’s size through your imageDelivery: slot. A photo shot at 4:3 unfurls letterboxed or badly cropped in a 1.91:1 slot, and this is what fixes it. The crop is URL math, so it costs no CPU and reaches no rasterizer. With no delivery slot configured the photo is emitted as it stands rather than dropped.
  3. The generated card.
  4. Your site-wide default, the SEO plugin’s default social image.

A card takes the second slot for itself when its rule says so:

card.forEntryType("post").define({
mode: "card",
key: ({ data }) => cardKey.entry(data.entry, data.entry.title),
render: ({ data }) => ({ type: "text", text: data.entry.title }),
});

mode: "card" shares the card even on an entry that has a photo — the choice for a theme whose share image is branded rather than the picture itself. The default, "auto", steps aside for the photo. A card the page cannot advertise at all, such as one from svgOnly(), still yields to the photo whatever mode says.

Cropping is a feature of this plugin, and it takes its target size from the card rule that matched. A site that wants cropped share images but no generated cards still declares a rule, to say what shape to crop to.

An image an earlier seo.ogImage handler supplied is left exactly as it arrived — neither outranked nor cropped. A subscriber registered after og() sees the card and can replace it, so the array order decides who has the last word.

Two things leave a page with no card in its head, and they do not go equally far.

An entry a scraper could not reach has none, and its card URL answers 404 rather than merely going unadvertised — the head and the route ask one question, so the head can never promise a URL the route refuses. Three things fail it: the entry is not published, its type is unregistered or not public, or the access layer refuses it. That last one is asked about an anonymous visitor whoever is calling, because a card carries the entry’s title, sits at a sequential id and is served from a shared cache. A soft gate is the exception. Its page serves a public teaser at 200, so the teaser unfurls with a card like any other page.

A renderer whose output scrapers do not render is the other, and it is head-only: the route still serves the card, so you can look at it, but only PNG and JPEG reach a head — an SVG og:image unfurls as nothing at all.

A render that throws cannot take back the URL the page already advertised. The route redirects to your site-wide default image instead and logs the failure as og_card_render_failed, because a scraper would show an error answer as a broken image. In development the error reaches the dev error page instead, with its stack.

The bundled card paints from three of your theme’s color tokens: background for its ground, foreground for the headline, and muted-foreground for the site name beneath it. A theme spelling its palette those three ways gets a card in its own colours for declaring nothing at all.

Most themes spell them their own way. Name the roles yours does:

og({
palette: { background: "paper", foreground: "ink", mutedForeground: "muted" },
});

Each key is a role the card paints and each value is one of your color token slugs. A role you leave out keeps the convention name. The keys are camelCase because they are TypeScript; the slugs they default to are kebab-case because that is what defineTheme accepts.

The card takes your palette only when all three resolve. Name two and it keeps its own three rather than mixing them, because the theme’s paper under the bundled card’s near-white ink is an unreadable card — worse than one that merely looks unlike the site. A token declared without a value does not resolve either: a card renders away from the page, where your own stylesheet never loads, so a custom property your CSS defines is one the card cannot read.

Only colour follows the theme. The card’s spacing and type sizes are its own, so a spacing token changes nothing about it — a card is one fixed 1200×630 composition rather than a page that reflows.

Only the bundled card reads any of this. A card your theme declares styles itself from the same tokens directly, under whatever names it likes.

A theme adds an ogCards array beside its templates, in the vocabulary it already uses: a generic tier, or a targeted matcher narrowed the same way. The first rule that matches wins, and anything a theme declares sits ahead of the bundled default.

import { defineTheme } from "plumix/theme";
import { card, cardKey } from "@plumix/plugin-og";
export default defineTheme({
templates: [],
ogCards: [
card.forEntryType("post").define({
key: ({ data }) => cardKey.entry(data.entry, data.entry.title),
styles: [
".card { display: flex; padding: 72px; background: var(--plumix-color-ground) }",
".card__title { font-size: 76px; color: var(--plumix-color-ink) }",
],
render: ({ data }) => ({
type: "container",
className: "card",
children: [
{ type: "text", className: "card__title", text: data.entry.title },
],
}),
}),
card.fallback().define({
key: () => cardKey.of("site"),
render: () => ({ type: "container" }),
}),
],
});

forEntryType("post") autocompletes against your registered types and rejects a typo at compile time, and both callbacks receive that type’s entry projection. A card declares template deps — settings, menus and the rest — as arrays of keys and receives their results alongside data and ctx.

Styling is ordinary CSS against ordinary class names. Whatever your theme declared in tokens is compiled to a :root block of custom properties and handed to the renderer ahead of the card’s own stylesheet, so var(), calc() and the --plumix-<category>-<slug> names your site’s CSS already reads all resolve. The same tokens arrive in both callbacks as resolved values, for what a card decides in JavaScript rather than in CSS.

key names everything the card read. It is required rather than derived, because a card reading a setting or a dep has an input no derivation can see. The helpers keep it to one line:

  • cardKey.entry(entry) — one entry, keyed on its updatedAt so an edit reaches the card at a new URL.
  • cardKey.of("home", locale) — anything else, keyed on what you name, because only you know what such a card read.

Three inputs fold in without being named: the card’s own source, so a redesign invalidates what it replaced with no version bump; the font set; and the theme’s tokens. The source covers the card’s own render and styles, not a component render calls out to. A card whose design lives in a child component needs the child’s identity in its key.

Two do not, and the answer to both is to name them:

  • updatedAt holds whole seconds, so two edits inside one second share a key. A card rendering entry content should name that content — cardKey.entry(entry, entry.title).
  • An image is resolved during the render, so nothing about it reaches the key. A card painting one should name what identifies it, as in cardKey.entry(entry, hero.url), since a replaced upload lands on a new storage key. Unpublishing an image moves no key, so a card already rendered keeps showing it.

Two more are consequences to know rather than things a key can fix. Two renderers declaring the same content type share keys, so swapping between them serves what the previous one stored. And nothing deletes a card’s predecessor: every key change leaves the old object in your bucket, and the URL it was served at redirects to the current card.

A card paints an image with an image node, and the plugin resolves what it points at before anything renders:

card.forEntryType("post").define({
key: ({ data }) =>
cardKey.entry(data.entry, data.entry.title, data.entry.meta.hero.url),
render: ({ data }) => ({
type: "container",
className: "card",
children: [
{
type: "image",
src: data.entry.meta.hero.url,
width: 1200,
height: 360,
},
{ type: "text", text: data.entry.title },
],
}),
});

A media reference’s url resolves in both shapes it takes: the bucket’s own public URL, and the /_plumix/media/serve/<id> route on a bucket with none. The media has to be published, as that route requires. Use url rather than thumbnailUrl, which points at a transformed image that is not in the bucket and so does not resolve. A card cannot fetch an external image lists the other sources that do.

Six, each named by an identity a URL can carry. Every one of them is served from the bundled template with no theme configuration at all.

Page Target Shareable when
An entry entry/<id> Published, of a public type, and reachable by an anonymous visitor
A term archive term/<id> Its taxonomy has a public archive, and it lists something
An entry type’s archive archive/<type> The type’s archive is routed and anonymously readable, and lists something
An author archive author/<id> They have published something
A date archive date/YYYY[-MM[-DD]] Something was published in it
The front page front-page Always

A listing page that lists nothing answers 404, the way a draft entry’s card does. Its page still renders — an empty term archive is a real page — but a card is minted at an enumerable URL and kept immutable in your bucket, and that rule is what keeps the calendar from being three million of them, and author/<id> from being a walk through your user list on a site where nobody has published yet. The front page is the exception: it is the site, so it is shareable whether or not anything is on it.

An archive whose entry type carries an access policy is refused on the same terms its listing page is, asked of a scraper carrying no session — otherwise a theme card rendering data.entries would put gated titles on a public, immutable, edge-cached URL.

An archive a plugin registers with registerArchiveType has no card until a card.forArchiveType(name) rule names it. The rule is the opt-in, so the bundled template and a theme’s card.fallback() never reach one. With the rule in place, the page’s head carries the card like any other page’s, and nothing else is needed: no option on registerArchiveType, and no route or head code in the theme.

ogCards: [
card.forArchiveType("location").define({
key: ({ data }) => cardKey.of("location", data.place),
render: ({ data }) => ({ type: "text", text: `Courses in ${data.place}` }),
}),
];

The target names the page by the route parameters it was matched with, keys sorted and each name and value one URL-encoded segment: /london on a /:location route is archive-type/location/location/london, and /leeds/maths on /:location/:catalogue is archive-type/location/catalogue/maths/location/leeds. An archive with several routes needs nothing more. The route rebuilds the page by calling the archive’s own resolve with those parameters as an anonymous visitor, so a card exists only where a page does.

The archive Shareable when
Resolves the parameters resolve returns a page rather than null or pageNotFound()
Carries an access policy The policy grants an anonymous visitor the page
Declares entries It lists something; the card renders from page one

The stored card is stored at the edge under what resolving its page read: the entries a listing archive lists, and whatever an archive’s resolve read through core’s read helpers or ctx.cache.reads. A write that purges the page purges its card.

Three surfaces show what a card looks like, and why a page has none.

/_plumix/og/preview renders every declared rule against invented sample data, one card per rule, listed in the order a page resolves against them rather than the order they were declared in. The preview also serves each card on its own at /_plumix/og/preview/<n>.<ext>. Storage and the CDN are both bypassed, so a refresh re-renders and an edit shows up — a served card cannot, since its URL is the immutable one described above. The preview never looks the sample data up, so it works on a site with no content yet. A rule’s matcher supplies the names it narrows on, so card.forEntryType("recipe") previews a recipe.

The debug bar’s OG image panel names which of the four links above produced the page’s image, which rule produced a card, and the reason there is no card. The panel also reports a renderer whose format scrapers cannot read. There is no boot-time warning for it. “I set a featured image and nothing changed” reads back as the card steps aside for the featured image.

A preview in the entry editor, for the entry types you name:

og({ preview: ["post", "page"] });

Each named type gains a Social card box in the editor rail: the image the entry will be shared with, and a line naming which link produced it. It renders on request and reads nothing back from storage, so a draft has one too, and it sees through a pending autosave. A render costs CPU, so the box fetches once and has a Refresh button for after you save. An entry a scraper could never reach, such as one of a private type or behind an access policy, gets no card here either. With remote({ url }) as the renderer, every preview posts the draft’s title and card content to that endpoint. It shows what will be shared; it does not change it. There is no per-entry override. It also models this plugin’s own contribution to the chain, so a seo.ogImage handler registered after og() can change what the page advertises without changing what the box says.

The list is not defaulted, because a meta box is registered against entry types by name and a name nothing registered fails the boot. Leave preview out and neither the box, the procedure behind it, nor the plugin’s admin chunk is registered at all.

Both dev surfaces sit behind the same development gate core uses for its own, reached through a dynamic import, so neither leaves anything in a production build. Both answer only over loopback. To review cards on a phone or through a tunnel, start the dev server with PLUMIX_DEV_ALLOW_REMOTE=1, as The Dev Server describes.

og({ fonts: ["/fonts/Inter-SemiBold.ttf"] });

Paths point into the platform asset layer — your .plumix/public directory, served through the ASSETS binding — and are read at render time, so a declared font costs nothing in the Worker bundle. Declare none, as a scaffolded site does, and the engine’s own face is used. A path that answers anything but 200 fails the render outright rather than quietly dropping to that face.

Which formats are read is your renderer’s business, not this option’s. A renderer declares what it reads beside what it writes, and the plugin fetches only the configured faces it can parse, in the order you listed them.

Renderer Reads
takumi(), svgOnly(), default TTF, OTF and WOFF — not WOFF2
remote({ url }) nothing: the endpoint brings its own faces
remote({ url, fonts }) whatever formats you declare the endpoint parses

WOFF2 is what most font packages ship by default, and the bundled engine cannot read it. Configure a set the connected renderer can parse none of and the card fails with an error naming the format, rather than serving a correct render of a font with no glyphs.

A renderer that reads no fonts ignores fonts entirely — nothing is fetched, nothing is sent, and a runtime with no asset layer at all still serves every card. The setting is then dead config, which the debug bar’s OG image panel points out in development. It also drops out of the card’s URL: a card’s digest names only the faces its renderer actually receives, so swapping a font path moves no URL on a site rendering off-box.

An endpoint that would rather render in your site’s own faces asks for them by declaring what it parses:

import { og, remote } from "@plumix/plugin-og";
og({
fonts: ["/fonts/Inter-SemiBold.woff2"],
renderer: remote({
url: "https://cards.example/render",
fonts: { formats: ["woff2"] },
}),
});

Faces then travel base64 in the request body, beside the images that already do — never as URLs, because an endpoint handed a URL has to fetch it, which is the class of problem this plugin removes rather than mitigates. The bill is the reason it is opt-in: several hundred KB per face on every render, and several MB for a CJK face.

Each of these fails without an error message.

Rasterizing in the Worker needs a paid plan

Section titled “Rasterizing in the Worker needs a paid plan”

The Workers free plan allows 10 ms of CPU per invocation, and rasterizing a card does not fit in it. That limit applies to scheduled handlers exactly as it does to fetch handlers, so precomputing cards on a cron does not route around it.

A free-plan site uses link two instead: the entry’s own photo, cropped and never rendered. Declare a card rule anyway — that is what names the shape to crop to.

Choosing the SVG renderer does not shrink the bundle

Section titled “Choosing the SVG renderer does not shrink the bundle”

renderer: takes the bundled engine (takumi(), or takumi({ format: "jpeg" }) for a photo-heavy design) or that same engine’s SVG output (svgOnly()), both on the @plumix/plugin-og/takumi subpath, or remote({ url }) from the package root to render off-box.

import { og } from "@plumix/plugin-og";
import { takumi } from "@plumix/plugin-og/takumi";
og({ renderer: takumi({ format: "jpeg" }) });

svgOnly() gives no bytes back: it runs through the same engine and loads the same wasm, around 2.3 MB against a Worker size ceiling of 10 MB gzipped on the paid plan and 3 MB on the free one. remote({ url }) is the only implementation that leaves the engine unexecuted, and the dependency stays installed either way, because the default renderer is resolved inside the package.

The engine is declared at an exact version rather than a range, so the copy rendering your cards is the copy this plugin’s tests rasterize with — and an upstream release does not reach you by installing again. That is deliberate: the engine is the only thing that turns a card into bytes, and a break in it does not throw, it unfurls wrong weeks later. Adopting a newer one is a plugin release, and the suite that rasterizes with the real engine is what gates it. Until then your package manager’s overrides are the lever, and the render is yours to verify.

No image a card names is ever fetched. The plugin resolves every image node before the render and hands the renderer bytes, so three sources work and nothing else does: anything published in your media library, anything else in your storage bucket addressed by the URL the bucket mints for it, and a data: URI, which carries its own bytes and is the escape hatch for a small inline asset such as a logo. An https:// URL pointing anywhere else is dropped, and the card renders without it.

That removes a class of problem rather than mitigating it: the equivalent Nuxt module has shipped fixes for advisories rooted in exactly this — a renderer steered into fetching a URL someone else chose. It also keeps a render deterministic and keeps the key honest, since a fetched image would be an input the key could not see. The same discipline runs through the route, against the other root those advisories share: no render option is ever read from a card’s URL. If you need a remote image on a card, put it in your media library.

The storage: and imageDelivery: slots this plugin reads are two of the twenty in Configuration, and Bindings covers the R2 bucket behind the first of them on Cloudflare. Overview covers installing a plugin and the version track this one is on, and Scaffolding covers the wizard that writes the wiring above.

Cards are declared where a theme declares its templates: Templates covers the builders card.* mirrors, Template Hierarchy the tier-and-matcher resolution both share, and Template Data the data a card’s callbacks receive. The tokens a card styles with are in Themes.

The featured and ogImage roles that outrank a card are filled by meta-box fields — Meta Boxes covers declaring them and Image Roles what a role is and how to add one of your own. Whether an entry may carry a card at all follows Statuses and Publishing and the model in Access & Identity.

Read Blog if the site has no entry type yet: a card hangs off a published entry, so post or page comes first.

To design a card rather than take the bundled one, Templates is the vocabulary ogCards reuses, and Cards your theme declares covers the key helpers and images in a card.

To put the cropped-photo path to work on the free plan, Configuration covers the imageDelivery slot that does the cropping and Cloudflare Workers the adapter behind it.