New:Socket for Asana Is Now Available.Learn more
Get Started

metaplate

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

metaplate

Composable, framework-neutral Open Graph image tooling for TypeScript.

Source
npmnpm
Version
0.3.0
Version published
Weekly downloads
76
-95.26%
Maintainers
1
Weekly downloads
 
Created
Source

Metaplate

CI CodeQL npm License: MIT

Composable, framework-neutral Open Graph image tooling for TypeScript.

Metaplate turns one branded JSX plate into a consistent image system: SVG and PNG rendering, Fetch API responses, predictable image URLs, matching Open Graph and Twitter metadata, package-based font loading, and PNG verification. It works with plain Node, Astro, SvelteKit, Remix, Express, static build scripts, and Next.js.

Install

Metaplate has no runtime dependencies of its own. Each entry point declares the peers it needs, so metadata-only and Next.js projects never download Satori or Resvg's platform-specific binaries.

For metadata, or for Next.js where next and react are already supplied by the application:

npm install metaplate

For framework-neutral PNG rendering with metaplate/node:

npm install metaplate satori @resvg/resvg-js react

For SVG-only rendering with metaplate/render, Resvg is unnecessary:

npm install metaplate satori react

Optional peers

Every peer — satori, @resvg/resvg-js, and next — loads on the first render rather than at import time, so each entry point imports cleanly in an install that lacks it. A render without the peer reports the package to install:

Cannot find satori, required by metaplate/render and metaplate/node.
Install it with: npm install satori

Through 0.1.x Satori and Resvg were ordinary dependencies. Standalone consumers upgrading from those versions should add them to their own package.json; nothing changes for metadata-only consumers, and Next.js applications already supply next themselves.

metaplate/next no longer re-exports ImageResponse. Import it from next/og directly if a plate needs it:

import { ImageResponse } from "next/og";

Upgrading in place does not reclaim the disk. An already-installed satori or @resvg/resvg-js satisfies the now-optional peer, so npm considers the tree valid and leaves both packages where they are; npm prune makes it worse, proposing Resvg's entire platform matrix rather than removing anything. Reinstall from scratch to shed them:

rm -rf node_modules package-lock.json && npm install

Measured on a metadata-only consumer: 19 MB retained after an in-place upgrade from 0.1.2, against 164 KB after a clean reinstall.

Framework-neutral renderer

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 { packageFontLoader } from "metaplate/fonts";
import { createNodeOg } from "metaplate/node";

export const og = createNodeOg<{ title: string; alt: string }>({
  alt: (copy) => copy.alt,
  fonts: packageFontLoader([
    {
      name: "Inter",
      package: "@fontsource/inter",
      file: "files/inter-latin-700-normal.woff",
      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 packageFontLoader memoizes one shared copy of the font bytes, so a pool over render is the expected way to build many cards at once.

Other output formats

PNG suits a flat vector plate and is what render returns. 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.

renderPixels returns the raw pixmap so any encoder can take it, which keeps an image encoder out of this package:

import sharp from "sharp";

const { pixels, width, height } = await og.renderPixels(copy);

await sharp(pixels, { raw: { width, height, channels: 4 } })
  .jpeg({ quality: 80 })
  .toFile("public/og-image.jpg");

The bytes are row-major RGBA, width * height * 4 long, and the same shape @jsquash/jpeg, @jsquash/webp, and sharp all accept.

Two things to know when leaving PNG behind: point imagePath at the extension actually written, so socialImage and socialImageMetadata describe the real file, and note that metaplate verify reads PNG headers only — a JPEG or WebP card is not covered by it.

Astro, SvelteKit, Remix, and other Fetch-based routes

handler returns a standard Fetch API handler. For an Astro endpoint:

// src/pages/og-image.png.ts
import { og } from "../lib/og";

export const prerender = true;
export const GET = og.handler({ title: "An Astro site", alt: "Astro card" });

The same handler shape works in SvelteKit and other route systems that return a Web Response.

Express and build scripts

Express can send the raw PNG returned by render. 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.png", await og.render(copy));

Authoring without a JSX toolchain

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 { packageFontLoader } from "metaplate/fonts";
import { createNodeOg } from "metaplate/node";

const og = createNodeOg({
  alt: (copy) => copy.alt,
  fonts: packageFontLoader([
    {
      name: "Inter",
      package: "@fontsource/inter",
      file: "files/inter-latin-700-normal.woff",
      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 when React is not installed at all, which is what the standalone package verification does.

SVG-only rendering

Use createSvgOg from metaplate/render when the consumer only needs SVG and should not install Resvg's native Node binding.

Next.js adapter

Next applications can use the native next/og pipeline while keeping the same route and metadata pattern:

// src/lib/og.tsx
import { packageFontLoader } 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: packageFontLoader([
    {
      name: "Inter",
      package: "@fontsource/inter",
      file: "files/inter-latin-700-normal.woff",
      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.

Metadata without a renderer

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 },
});

Next.js static export and basePath

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-image holds 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 metadataBase without applying basePath, so the tag reads https://example.github.io/opengraph-image and 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.

Fonts

Satori needs real font bytes and accepts TTF, OTF, and WOFF, but not WOFF2. packageFontLoader reads faces from installed packages and walks upward through node_modules, so hoisted workspace dependencies work. It memoizes the bytes for repeated development requests.

Plate constraints

A plate is a Satori layout that rasterises to an image, not a DOM tree. Three 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 the alt the 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/noSvgWithoutTitle or jsx-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 whose children is an array; see Authoring without a JSX toolchain.

Static hosts

Extension-free route-handler output may be served as a generic download by a static host. Set Content-Type: image/png explicitly for /og-image and /*/og-image. For Netlify:

[[headers]]
for = "/og-image"
  [headers.values]
  Content-Type = "image/png"

[[headers]]
for = "/*/og-image"
  [headers.values]
  Content-Type = "image/png"

Verify generated files

Metaplate checks the PNG signature, IHDR chunk, and dimensions without decoding the entire image:

npx metaplate verify --size 1200x630 public/og.png

Files 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.png

Or import verifyPng from metaplate/png in a test.

Entry points

  • metaplate — framework-free paths, dimensions, and metadata. No peers.
  • metaplate/render — Satori-based SVG generation. Needs satori.
  • metaplate/node — SVG and PNG generation plus Fetch API responses. Needs satori and @resvg/resvg-js.
  • metaplate/next — native Next.js ImageResponse adapter. Needs next.
  • metaplate/fonts — hoist-safe package font loading and memoization. No peers.
  • metaplate/png — PNG header inspection and dimension verification. No peers.

Design lineage

Metaplate extracts the production patterns used by the GOLC and Cinnabar sites and the pre-rendered static-image pattern used by the AntikytheraOS showcase.

License

MIT

Keywords

open-graph

FAQs

Package last updated on 24 Aug 2026

Related posts