Composable, framework-neutral Open Graph image tooling for TypeScript.
Metaplate turns one branded JSX plate into a consistent image system: SVG and raster rendering (PNG by default, any format an encoder produces), Fetch API responses, predictable image URLs, configurable Open Graph and X metadata, project-, framework-, and package-managed font loading, and image verification. It works with plain Node, Node-compatible framework adapters, static build scripts, and Next.js. Runtime compatibility depends on both the framework and its deployment adapter.
- Install
- Framework-neutral renderer
- Next.js adapter
- Metadata without a renderer
- Fonts
- Plate constraints
- Static hosts
- Verify generated files
- Entry points
- Design lineage
- License
One command installs Metaplate's complete framework-neutral renderer stack:
npm install metaplateThat installs compatible versions of satori, @resvg/resvg-js, and react
automatically. You do not need to discover or install renderer packages
separately. npm deduplicates these peer dependencies against compatible
versions already present in an application.
next remains the only optional peer: Next applications already own their
framework version, and non-Next applications should not download it. The
renderer peers are bounded to the release-tested major/minor lines instead of
silently accepting unknown breaking releases.
Dependencies load only when their entry point renders, so importing metadata
helpers does not initialize Satori or Resvg's native binding. Plain
{ type, props } authoring also remains independent of React APIs and React
types; the React runtime is included by the install so JSX works immediately.
metaplate/next no longer re-exports ImageResponse. Import it from next/og
directly if a plate needs it:
import { ImageResponse } from "next/og";Define the design once with createNodeOg. The component is Satori-compatible
JSX, not browser DOM, so containers with multiple children should use flex.
// src/lib/og.tsx
import { fontsourceFontLoader } from "metaplate/fonts";
import { createNodeOg } from "metaplate/node";
export const og = createNodeOg<{ title: string; alt: string }>({
alt: (copy) => copy.alt,
fonts: fontsourceFontLoader([{ font: "inter", weight: 700 }]),
headers: { "Cache-Control": "public, max-age=86400" },
component: (copy) => (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
background: "#111",
color: "#fff",
fontFamily: "Inter",
fontSize: 72,
padding: 72,
}}
>
{copy.title}
</div>
),
});The resulting plate supports three output forms:
const png: Uint8Array = await og.render(copy);
const svg: string = await og.renderSvg(copy);
const response: Response = await og.response(copy);Rendering is safe to call concurrently. Satori is a pure call, each render
builds its own Resvg instance, and the font loaders memoize one shared copy
of the font bytes, so a pool over render is the expected way to build many
cards at once.
PNG suits a flat vector plate and is what render returns by default. A card
that composites a photograph is a different problem: the same 1200x630 card
measures roughly 60 KB flat, 253 KB with a photo in it, and about 35 KB as JPEG
at quality 80. Across a per-item card set that difference decides whether the
set is publishable at all.
Metaplate ships no image encoder. Declare one and the plate carries the format
end to end — render returns the encoded bytes, response and handler serve
the media type that follows from it, and the metadata points at the declared
imagePath:
import sharp from "sharp";
export const og = createNodeOg<Copy>({
alt: (copy) => copy.alt,
fonts,
component,
imagePath: "og-image.jpg",
output: {
format: "jpeg",
encode: ({ pixels, width, height }) =>
sharp(pixels, { raw: { width, height, channels: 4 } })
.jpeg({ quality: 80 })
.toBuffer(),
},
});The encoder receives row-major RGBA, width * height * 4 long — the shape
sharp, @jsquash/jpeg, and @jsquash/webp all accept. format names the
bytes the encoder produces: contentType (and og:image:type) derive from
it, and every render verifies the encoded bytes' signature against it, so a
plate cannot report one format while emitting another. A JPEG encoder that
starts returning WebP bytes fails the render it was changed in, rather than
silently mislabelling the card on the site.
For a format Metaplate does not recognize, keep contentType and opt out of
the check explicitly:
output: {
contentType: "image/avif",
checkSignature: false,
encode: ({ pixels, width, height }) => avifEncoder(pixels, width, height),
}For a build script that writes files rather than serving them, renderPixels
hands back the same pixmap without going through an encoder at all:
const { pixels, width, height } = await og.renderPixels(copy);Point imagePath at the extension actually written, so socialImage and
socialImageMetadata describe the real file. metaplate verify reads PNG,
JPEG, and WebP, so the build check follows the card whichever format it takes.
handler returns a zero-argument Fetch API handler for fixed copy. This Astro
static endpoint
is typechecked as an APIRoute; Astro calls its GET export during the build:
// src/pages/og-image.png.ts
import type { APIRoute } from "astro";
import { og } from "../lib/og";
export const prerender = true;
export const GET = og.handler({
title: "An Astro site",
alt: "Astro card",
}) satisfies APIRoute;For dynamic copy, handlerFrom forwards every framework argument to a sync or
async resolver. For example, a SvelteKit
+server route deployed with the
official Node adapter can use its
typed params without a wrapper around every plate:
import type { RequestHandler } from "./$types";
import { og } from "$lib/og";
export const GET: RequestHandler = og.handlerFrom(({ params }) => ({
title: titleFor(params.slug),
alt: `${params.slug} card`,
}));Current React Router framework-mode
resource routes use
loader(args), not a GET export. Return the same Web Response from a
resolver:
export const loader = og.handlerFrom(({ params }: Route.LoaderArgs) => ({
title: titleFor(params.slug),
alt: `${params.slug} card`,
}));These routes require a Node-compatible deployment adapter because
metaplate/node loads Resvg's native Node binding. A framework implementing
Web Response does not by itself make its edge runtime compatible.
Express can send the bytes returned by render — PNG by default, or whatever
output encodes. Convert the Uint8Array to a Buffer, set the plate's exact
media type with res.type, send it
with res.send, and preserve
Express error handling:
app.get("/og-image.png", async (_request, response, next) => {
try {
response.type(og.contentType);
response.set("Cache-Control", "public, max-age=86400");
response.send(Buffer.from(await og.render(copy)));
} catch (error) {
next(error);
}
});Static generators can write the same bytes into public/ during a build:
import { writeFile } from "node:fs/promises";
import { og } from "./og.js";
await writeFile("public/og-image.jpg", await og.render(copy));The upstream links below define each framework's routing and deployment contract; this guide documents the Metaplate-specific mapping and the narrower set actually exercised by the release gate.
| Integration | Official framework reference | Supported runtime | Release evidence |
|---|---|---|---|
metaplate/next |
Metadata and OG images, metadata files, and static exports | Next.js 16.3.2–16.x Node/build pipeline | Exact packed artifact is built through a real Next static export in the release gate. Next 15 is not claimed because its remaining dependency advisories fail this project's release audit. |
| Astro static endpoints | Static and server endpoints | Astro 7 build on Node 24 | Exact packed artifact produces the endpoint, PNG bytes, dimensions, and absolute page metadata. |
| React Router resource routes | Resource routes | React Router 7 framework mode on Node | Exact packed artifact is type-generated, typechecked, built, served, and fetched through a dynamic loader(args) route. |
| SvelteKit | +server routing and adapter-node |
Node-compatible adapters | handlerFrom follows its RequestHandler contract, but certification is deferred while the latest stable Kit line retains an upstream Cookie advisory. |
| Express | Express 5 response API | Express 5 on Node | Exact packed artifact is served over an ephemeral HTTP server and checked for headers, bytes, and dimensions. |
| Workers, Deno, and other edge runtimes | Consult the framework's adapter/runtime documentation | Not supported by metaplate/node |
Native Resvg cannot be inferred from Web Response support. Use a compatible renderer instead. |
Public dynamic image routes are CPU- and memory-intensive. Bound copy length and component complexity, use stable path params rather than arbitrary query strings, set an explicit cache policy, and apply deployment-level concurrency and timeout limits. Never pass a request-controlled remote image URL into a Satori component: allowlist asset origins so server-side rendering cannot be used to reach private or link-local services.
A plain .mjs build script has no JSX transform, and adding one to render a
social card is rarely worth it. component accepts the element tree Satori
walks, so createElement is enough:
// scripts/build-og.mjs
import { writeFile } from "node:fs/promises";
import { createElement as h } from "react";
import { fontsourceFontLoader } from "metaplate/fonts";
import { createNodeOg } from "metaplate/node";
const og = createNodeOg({
alt: (copy) => copy.alt,
fonts: fontsourceFontLoader([{ font: "inter", weight: 700 }]),
component: (copy) =>
h(
"div",
{
style: {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
background: "#111",
color: "#fff",
fontFamily: "Inter",
padding: 72,
},
},
h("div", { style: { fontSize: 32 } }, copy.eyebrow),
h("div", { style: { fontSize: 72 } }, copy.title),
),
});
await writeFile("public/og-image.png", await og.render(copy));Satori requires an explicit display on any element whose children is an
array, including a single-element array. Pass a lone text child as a string,
h("div", style, "Roadmap"), not h("div", style, ["Roadmap"]). The array
form fails with:
Expected <div> to have explicit "display: flex", "display: contents",
or "display: none" if it has more than one child node.
That message names the containing element and its child count, but the element
to fix is the leaf holding the array. Scripts that build children
programmatically should either spread the array into createElement or give
that element an explicit display.
The same tree can be written as plain { type, props } objects without
importing React or relying on its types. That path is typed, not just
runtime-supported: createSvgOg and
createNodeOg declare component as returning a local SatoriNode element
tree rather than React's ReactNode, so a TypeScript consumer does not need
React types to author a plain-object plate. The Next adapter keeps React's own
types because Next itself is intrinsic to it.
Use createSvgOg from metaplate/render when the consumer only needs SVG. It
does not load or execute Resvg's native Node binding.
Next applications can use the native next/og pipeline while keeping the same
route and metadata pattern. Read this alongside Next's official
Metadata and OG images
and
opengraph-image file convention:
// src/lib/og.tsx
import { fontsourceFontLoader } from "metaplate/fonts";
import { createNextOg } from "metaplate/next";
export type OgCopy = {
eyebrow: string;
title: string;
description: string;
alt: string;
};
export const og = createNextOg<OgCopy>({
alt: (copy) => copy.alt,
fonts: fontsourceFontLoader([{ font: "inter", weight: 700 }]),
component: (copy) => (
<div style={{ width: "100%", height: "100%", display: "flex" }}>
{copy.title}
</div>
),
});Next shallow-merges metadata:
a page that sets openGraph replaces the root layout's rather than extending
it. Spreading og.metadata() straight into
a page therefore drops every other Open Graph field the layout contributed —
siteName, type, locale, url — from that page's tags. Nothing errors and
the build stays green; the loss shows only in the emitted HTML.
Write the composition once, next to the plate, and call it from each page:
// src/lib/metadata.ts
import type { Metadata } from "next";
import { og, type OgCopy } from "./og";
/** Site-level fields the root layout spreads into its own `openGraph`. */
export const openGraph = {
siteName: "Example",
type: "website",
locale: "en_US",
};
export function pageMetadata(route: string, copy: OgCopy): Metadata {
const social = og.metadata(route, copy);
return {
title: copy.title,
description: copy.description,
openGraph: {
...openGraph,
// `url` is per-route, so it cannot live in the shared constant, and the
// layout's own `url` is replaced along with everything else.
url: route,
// Without this, Next fills `og:title` from the document title, including
// any `title.template` suffix the layout defines.
title: copy.title,
description: copy.description,
images: social.openGraph.images,
},
twitter: social.twitter,
};
}Keep the copy next to the page it describes:
// src/app/roadmap/page.tsx
import { pageMetadata } from "@/lib/metadata";
export const copy = {
eyebrow: "What comes next",
title: "Roadmap",
description: "A dependency-ordered view of the work ahead.",
alt: "Project roadmap",
};
export const metadata = pageMetadata("/roadmap", copy);One function rather than a spread per page is deliberate: the fields above have
to be restated on every route that sets openGraph at all, and a route that
forgets one loses it silently.
If the layout sets no Open Graph fields and neither does the page, spreading the whole result stays correct:
export const metadata = { title: copy.title, ...og.metadata("/roadmap", copy) };Then expose the predictable route:
// src/app/roadmap/og-image/route.tsx
import { og } from "@/lib/og";
import { copy } from "../page";
export const dynamic = "force-static";
export const GET = og.handler(copy);For Next's opengraph-image.tsx convention, call og.render(copy) from the
default export and re-export og.size and og.contentType as its constants.
That convention assumes a root-deployed app; see
Next.js static export and basePath
before using it behind a deployment prefix.
A plate renders exactly one size. plate.size is both the definition size and
the size render/renderSvg/response use, so the bytes Metaplate produces
and the dimensions it advertises (og:image:width/height) can never
disagree. Size values must be integers between 1 and 65535; socialImage,
socialImageMetadata, and every plate definition reject anything else at the
boundary.
The root metaplate entry has no framework dependency. It can describe a
hand-authored or pre-rendered image, like a conventional public/og.png:
import { socialImageMetadata } from "metaplate";
const metadata = socialImageMetadata("/", "Project home card", {
imagePath: "og.png",
size: { width: 1200, height: 630 },
});Relative paths are the default because Next's Metadata API resolves them against
metadataBase. A framework-neutral consumer that writes tags directly can pass
an origin for crawler-ready absolute URLs; basePath, route, and imagePath
compose beneath it:
const metadata = socialImageMetadata("/docs", "Docs card", {
origin: "https://example.com",
basePath: "/project",
imagePath: "og-image.jpg",
});
// https://example.com/project/docs/og-image.jpgMetadata helpers accept route/basePath/imagePath as pathnames only:
query strings, fragments, and ./.. segments are rejected rather than
silently producing a URL that normalizes somewhere else.
Metaplate configures two published webpage metadata channels, not one platform:
| Metaplate option | Emitted/returned channel | Typical consumers |
|---|---|---|
openGraph / OpenGraphImageOptions |
og:image and its structured properties |
Facebook, LinkedIn, Slack, Mastodon, Discord, and other Open Graph readers |
twitter / XImageOptions |
twitter:card, twitter:image, and identity fields |
X Cards |
The public option remains named twitter because twitter:* is still the X
Card wire protocol and because frameworks such as Next.js expose the same
twitter metadata field. XCard and XImageOptions are the preferred
human-facing type names; TwitterCard and TwitterImageOptions remain exact
aliases for existing code and framework terminology. Platform-specific
delivery rules are selected separately with SocialTarget in
socialImageCompatibility.
The one-image call remains unchanged. When a landscape Open Graph image, a square fallback, and an X-specific composition differ, override only those channels. Open Graph ordering is preserved and the first descriptor remains the preferred image:
const metadata = socialImageMetadata("/docs", "Docs card", {
origin: "https://example.com",
imagePath: "og-image.png",
openGraph: { images: [landscape, square] },
twitter: {
card: "summary",
image: xCard,
site: "@example",
creator: "@author",
},
});Supported X identity fields are site, siteId, creator, and creatorId.
Overrides are copied into independent descriptors, so mutating a source object
later cannot silently change or desynchronize the two channels. Metaplate does
not generate fictional discord:* or instagram:* tags; those consumers use
Open Graph or undocumented heuristics rather than a separate page schema.
socialImageCompatibility checks local descriptor facts without making
network requests. The conservative universal profile requires an absolute
HTTPS URL and PNG/JPEG media type; named profiles add documented checks,
including LinkedIn's dimensions and optional 5 MB limit:
import { socialImageCompatibility } from "metaplate";
const report = socialImageCompatibility(metadata.openGraph.images[0], {
targets: ["universal", "facebook", "linkedin", "slack"],
fileSize: generatedBytes.byteLength,
});
if (!report.compatible) throw new Error(JSON.stringify(report.issues));Issues are error, warning, or unknown. Discord and Instagram checks are
reported as unknown because neither publishes a stable webpage image-tag
contract. SVG remains useful as renderer output, but it is not a universal
social delivery format; use PNG or JPEG for broad crawler compatibility.
This local report cannot prove public fetchability, redirects, response MIME, robots/WAF behavior, or crawler caches. Those require checking the deployed page and image; use Meta Sharing Debugger, LinkedIn Post Inspector, and the relevant client debugger after deployment.
Next's special app/opengraph-image.tsx file suits a root-deployed app: set
dynamic = "force-static" and Next prerenders the ImageResponse during
next build with output: "export"
enabled.
Under a deployment basePath, that file still prerenders and the build still
reports success, but the card is unusable for two independent reasons:
- The emitted file has no extension.
out/opengraph-imageholds PNG bytes with nothing to tell a static host so. The Static hosts section fixes that for route handlers with per-path headers, which GitHub Pages project sites cannot set at all. - The emitted metadata drops the prefix. Next resolves special-file
metadata against
metadataBasewithout applyingbasePath, so the tag readshttps://example.github.io/opengraph-imageand 404s on a project site. The build stays green, so this surfaces only once a crawler follows the link.
Under basePath, render the card into public/ during the build instead, as
in Authoring without a JSX toolchain, and
describe the result with the framework-neutral metadata helper:
// app/layout.tsx
import type { Metadata } from "next";
import { socialImageMetadata } from "metaplate";
const social = socialImageMetadata("/", "Project home card", {
basePath: "/project",
imagePath: "og-image.png",
});
export const metadata: Metadata = {
metadataBase: new URL("https://example.github.io"),
openGraph: social.openGraph,
twitter: social.twitter,
};This emits /project/og-image.png, which Next resolves against
metadataBase into
https://example.github.io/project/og-image.png. Verify both
public/og-image.png and the copied out/og-image.png, and inspect the
exported HTML to confirm its social tags carry the deployment prefix.
Satori needs the actual bytes of every
rendered face and accepts TTF, OTF, and WOFF, but not WOFF2. This is separate
from a framework's CSS font setup:
next/font, for example,
self-hosts and exposes class names but does not expose its transformed font
bytes to ImageResponse. Reuse the same source file or npm package for the
plate; do not attempt to extract it from the framework's generated CSS.
Choose the loader that matches where the project already owns the font:
| Existing source | Loader | What you specify |
|---|---|---|
| Fontsource npm package | fontsourceFontLoader |
Font id plus optional weight/style/subset |
| Local project file | fileFontLoader |
Relative/absolute path or co-located file: URL |
| Existing bytes or framework fetch/import | fontLoader |
Bytes or a lazy byte callback |
| Any other npm package | packageFontLoader |
Package name and package-relative file |
All loaders memoize successful bytes across renders and retry after a failed
load. Fontsource is the shortest npm-managed path; it reads the installed
package's family and default subset from metadata.json and selects its WOFF
face, so no internal filename needs to be copied into application code:
import { fontsourceFontLoader } from "metaplate/fonts";
const fonts = fontsourceFontLoader([
{ font: "inter", weight: 400 },
{ font: "inter", weight: 700 },
]);Install the corresponding package once (npm install @fontsource/inter). This
uses Fontsource's official npm self-hosting model
while selecting WOFF instead of the WOFF2 normally used by browser CSS.
For a font already checked into the project, point at it directly in a Node-compatible renderer:
import { fileFontLoader } from "metaplate/fonts";
const fonts = fileFontLoader([
{
name: "Brand Sans",
file: new URL("../assets/brand-sans.woff", import.meta.url),
weight: 700,
},
]);For an edge runtime or a framework that already resolves/fetches an asset, give Metaplate the resulting bytes. The callback stays lazy and is evaluated once:
import { fontLoader } from "metaplate/font-data";
const fonts = fontLoader([
{
name: "Brand Sans",
weight: 700,
data: async () => {
const response = await fetch(new URL("../assets/brand-sans.woff", import.meta.url));
if (!response.ok) throw new Error(`Unable to load font: ${response.status}`);
return response.arrayBuffer();
},
},
]);Vite documents asset URL imports, but also
notes that new URL(..., import.meta.url) has different semantics in SSR. In
Astro and React Router Node deployments, prefer fileFontLoader for
server-owned files; use fontLoader only when the framework/deployment already
gives the server a fetchable URL or bytes.
packageFontLoader remains the escape hatch for other npm font packages. It
resolves through the active runtime first (npm, Yarn classic, pnpm, and hoisted
workspaces), then falls back to an upward node_modules walk:
Install layouts without a physical node_modules — Yarn Plug'n'Play — cannot
be read by path at all. Supply a resolvePackage hook that maps a package
name to a readable directory (an unplugged path, or a zipfs-backed view of
the archive):
import { packageFontLoader } from "metaplate/fonts";
const fonts = packageFontLoader([
{ name: "Inter", package: "@fontsource/inter", file: "files/inter-latin-700-normal.woff", weight: 700 },
], {
resolvePackage: (name) => zipfsResolveToReadableDir(name),
});Return undefined to fall back to the default resolution.
A plate is a Satori layout that rasterises to an image, not a DOM tree. Four differences bite in practice:
- Inline SVG
<title>renders as visible text. Satori supports a subset of SVG and lays out an unsupported element's children as text, so a<title>inside an inlined logo prints the word across the mark. Leave it out: the accessible name for a social card is thealtthe plate already derives, and an element inside a PNG is unreachable to assistive technology anyway. - React accessibility lint rules do not apply. Rules such as Biome's
lint/a11y/noSvgWithoutTitleorjsx-a11y/*are written for DOM SVG and will ask for exactly the<title>above. Suppress them in the plate file rather than satisfying them. - Layout rules are Satori's, not the browser's. Elements with more than one
child need an explicit
display, as does any element whosechildrenis an array; see Authoring without a JSX toolchain. - Resvg may not resize the raster behind the plate's back. Dimension-changing
fitTovalues andcropare rejected because metadata,plate.size, raw pixels, and encoded output must agree. Define the intendedsizeon the plate instead.
Extension-free route-handler output may be served as a generic download by a
static host. Set the Content-Type explicitly for /og-image and /*/og-image
— image/png by default, or the plate.contentType of a custom-output plate
(image/jpeg, image/webp, …). For Netlify, a PNG plate:
[[headers]]
for = "/og-image"
[headers.values]
Content-Type = "image/png"
[[headers]]
for = "/*/og-image"
[headers.values]
Content-Type = "image/png"A JPEG plate uses Content-Type = "image/jpeg" for the same two paths.
metaplate verify reads dimensions from SVG roots and PNG, JPEG, or WebP
container headers. It runs a structural/truncation check: raster chunk streams
are walked through image data to their terminator, while SVG roots must declare
safe, positive pixel dimensions (without XML entity expansion). Obvious header
shells, malformed roots, and partially written files fail even when their
dimension data survives. It is not a full raster decode — a file whose headers
are intact but whose payload cannot decode is outside its scope:
npx metaplate verify --size 1200x630 public/og.pngFiles of different sizes can be checked in one invocation by repeating the size group:
npx metaplate verify \
--size 1200x630 public/og.png public/about.png \
--size 512x512 public/icon-512.pngMixed formats work in one invocation, since the format is detected per file, and every target is checked even when earlier ones fail — the command reports the full failing set and exits non-zero once:
npx metaplate verify --size 1200x630 public/og-image.jpg out/og-image.jpg --size 512x512 public/icon.webpWhen a declared format must also hold — for example a .jpg file that must
really contain JPEG — pass --format:
npx metaplate verify --format jpeg --size 1200x630 out/og-image.jpgOr import verifyImage from metaplate/image in a test, which returns the
format it verified alongside the dimensions. metaplate/png remains available
for PNG-only checks.
metaplate— framework-free paths, dimensions, and metadata.metaplate/render— Satori-based SVG generation. Satori is installed automatically.metaplate/node— SVG, PNG, raw pixels, and any format a supplied encoder produces, plus Fetch API responses. Satori and Resvg are installed automatically.metaplate/next— native Next.jsImageResponseadapter. Needsnext.metaplate/font-data— runtime-neutral normalization and memoization for application/framework-provided bytes. No peers or Node built-ins.metaplate/fonts— project-file, Fontsource, generic npm-package, and application-byte font loading for Node-compatible runtimes. No peers.metaplate/png— PNG header inspection and dimension verification. No peers.metaplate/image— dimension and structural verification for SVG, PNG, JPEG, and WebP. No peers.
Metaplate extracts the production patterns used by the GOLC and Cinnabar sites and the pre-rendered static-image pattern used by the AntikytheraOS showcase.
MIT