SvelteKit OG 4.4: Generate OG Images in the Browser

SvelteKit OG 4.4: Generate OG Images in the Browser

Introduction

SvelteKit OG 4.4 is out, and it adds something I've wanted for a while: rendering OG images in the browser. Until now, every image came from a server route. The new @ethercorps/sveltekit-og/client entry renders the same images on the main thread or in a web worker, with no server request.

This helps in two places:

  • Live previews. An editor or CMS can show the OG card as someone types, without calling an endpoint on every keystroke.
  • Static sites. An adapter-static site has no server, but can still generate images at runtime. It uses the same two engines as the server: Takumi (default) or Satori + ReSVG. The server API doesn't change; this release is purely additive.

You can try it before installing anything. The new playground renders in your browser with this API and shows the matching createImage(...) call.

Installation

npm i @ethercorps/sveltekit-og takumi-js
Enter fullscreen mode Exit fullscreen mode

Here, I'm installing takumi-js alongside the package. You need it even if you only plan to use Satori, because Vite resolves the Takumi chunk at build time.

Two more requirements:

  • Vite. The client entry loads its WebAssembly and fonts through Vite ?url imports. Every SvelteKit app already uses Vite; other bundlers aren't supported.
  • Browser-only code. Set export const ssr = false; in the route's +page.ts, or only call the API inside onMount or an event handler. ## Your first image in the browser
<script lang="ts">
    import { createImage } from "@ethercorps/sveltekit-og/client";

    let src = $state<string | null>(null);

    async function render() {
        const html = `<div style="display:flex;width:100%;height:100%;align-items:center;justify-content:center;font-size:64px">Hello</div>`;
        const res = createImage(html, { width: 1200, height: 630, format: "png" });
        src = URL.createObjectURL(await res.blob());
    }
</script>

<button onclick={render}>Render</button>
{#if src}<img {src} alt="OG preview" />{/if}
Enter fullscreen mode Exit fullscreen mode

Here, I'm calling createImage with an HTML string. Takumi is the default engine, so there's no engine option in the above code example. createImage returns a standard Response, so I read it with .blob() and turn it into an object URL for the <img>.

If you prefer a class, ImageResponse is the same thing as a Response subclass. Read either one with .blob(), .arrayBuffer() or .text().

Choosing an engine

// Satori + ReSVG: png or svg
createImage(html, { engine: "satori", format: "svg", width: 1200, height: 630 });

// Takumi: png, jpeg, webp, ico, raw, svg
createImage(html, { engine: "takumi", format: "webp", quality: 80, width: 1200, height: 630 });
Enter fullscreen mode Exit fullscreen mode

Here, I'm picking the engine with the engine option. The options are a discriminated union on engine, so TypeScript narrows formats, fonts and emoji to the engine you chose. Satori raster formats other than png render as png.

Only the engine you use is downloaded. Each one is its own chunk behind a dynamic import(), so Takumi users never fetch Satori's WebAssembly, and the other way round.

Engine Output formats First-render download
Takumi (default) png, jpeg, webp, ico, raw, svg about 3.7 MB of wasm
Satori + ReSVG png, svg about 2.6 MB of wasm, plus 1.2 MB of default fonts if you pass none

Rendering Svelte components

import Card from "./Card.svelte";

const res = createImage(Card, { width: 1200, height: 630 }, { title: "Hello", subtitle: "from the browser" });
Enter fullscreen mode Exit fullscreen mode

Here, I'm passing a component instead of a string, with its props as the third argument. The package mounts it into a detached shadow root, captures the HTML, then unmounts it. The shadow root never joins the page, so your page CSS can't leak into the image.

One catch: only inline styles are captured. Scoped <style> blocks and css="injected" don't come through, so use inline styles or the stylesheets / Tailwind options.

Rendering in a web worker

Rendering also works inside a module worker, which keeps the main thread free while the wasm runs. First, set the worker format in vite.config:

export default defineConfig({
    plugins: [sveltekit()],
    worker: { format: "es" },
});
Enter fullscreen mode Exit fullscreen mode

Here, I'm switching Vite's worker format from the default iife to es. The client entry splits into one chunk per engine, and iife can't code-split. Without this, vite dev works but vite build fails.

Then render HTML strings inside the worker:

// worker.ts
import { createImage } from "@ethercorps/sveltekit-og/client";

self.onmessage = async (e: MessageEvent<{ html: string }>) => {
    const bytes = await createImage(e.data.html, { width: 1200, height: 630 }).arrayBuffer();
    self.postMessage(bytes, { transfer: [bytes] });
};
Enter fullscreen mode Exit fullscreen mode
// +page.svelte (script)
const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });

worker.onmessage = (e) => {
    src = URL.createObjectURL(new Blob([e.data], { type: "image/png" }));
};

worker.postMessage({ html });
Enter fullscreen mode Exit fullscreen mode

Here, I'm posting an HTML string to the worker and getting PNG bytes back. Transferring the ArrayBuffer hands it over without copying it.

Workers have no DOM, so components can't render there. Passing one rejects with the code COMPONENT_IN_WORKER, before any wasm or font download starts. Build the HTML string on the main thread and post that instead.

Fonts

Text renders with no font setup on either engine:

  • Takumi has a sans-serif built into its wasm.
  • Satori falls back to a bundled Noto Sans (regular and bold). It's served from your own site, so there are no cross-origin requests and it works offline. To use your own font, supply the bytes with CustomFont:
import { createImage, CustomFont, resolveFonts } from "@ethercorps/sveltekit-og/client";

const inter = new CustomFont("Inter", () => fetch("/fonts/Inter-Bold.ttf").then((r) => r.arrayBuffer()), { weight: 700 });

// Satori needs resolved font data
createImage(html, { engine: "satori", width: 1200, height: 630, fonts: await resolveFonts([inter]) });

// Takumi takes CustomFont directly
createImage(html, { engine: "takumi", width: 1200, height: 630, fonts: [inter] });
Enter fullscreen mode Exit fullscreen mode

Here, I'm loading Inter-Bold.ttf from static/fonts/. Satori wants resolved data, so the font goes through resolveFonts first; Takumi accepts the CustomFont as is.

GoogleFont isn't available on the client entry. Browsers can't change their User-Agent, so Google Fonts serves woff2, which neither engine accepts. Download the TTF and use CustomFont.

Handling errors

try {
    const blob = await createImage(html, { engine: "satori", width: 1200, height: 630 }).blob();
} catch (err) {
    console.error((err as { code?: string }).code); // e.g. "FONT_LOAD_FAILED"
}
Enter fullscreen mode Exit fullscreen mode

Here, I'm reading the error's code. Render failures reject from .blob(), .arrayBuffer() and .text() with an ImageResponseError coded as COMPONENT_IN_WORKER, FONT_LOAD_FAILED, SATORI_RENDER_FAILED, TAKUMI_RENDER_FAILED and so on.

That took a fix of its own. When a Response body stream errors, browsers collapse it into a bare TypeError: Failed to fetch. The client ImageResponse reads the stream itself in those three methods, so the coded error survives; clone() and bytes() still give you the generic one.

How it works

When you call createImage, this is what happens:

  1. The options are split: status, statusText and headers stay on the Response, and the rest go to the engine.
  2. If you passed a component, a lazily loaded mounter renders it into a detached shadow root and captures the HTML. Inside a worker, this step rejects with COMPONENT_IN_WORKER instead.
  3. If the engine is Satori and you passed no fonts, the bundled Noto Sans is fetched once and cached. A failed fetch isn't cached, so the next render retries.
  4. The chosen engine is loaded with a dynamic import(). Only now does the browser download that engine's wasm.
  5. The engine renders the HTML string to image bytes or an SVG string.
  6. The result streams into the Response body, ready for .blob(), .arrayBuffer() or .text().

Also in 4.4: SvelteKit 3 and Bun

Outside the client entry, 4.4 adds SvelteKit 3 support, which also opens the door to the new official Bun adapter:

  • SvelteKit 3: the Vite plugin now loads its wasm per environment and only touches the server build. It works on SvelteKit 3, and the client entry keeps working alongside it.
  • Bun: SvelteKit OG works with the official @sveltejs/adapter-bun, which needs SvelteKit 3 and Bun 1.4 or newer. Both engines run on Bun: keep sveltekitOG({ esmImport: false }) in vite.config.js and build with bun run --bun vite build. The Bun guide and the examples/bun-build app have the full setup.
  • Satori: injected component styles are normalized before parsing, and empty elements no longer trip the multiple-children check.

Limitations

  • Vite only, because of the ?url asset imports.
  • Components capture inline styles only, and workers take HTML strings only.
  • If you minify with terser's ascii_only, selectors like :not() break in the Satori path's HTML parser. Vite's default minifier is fine.
  • Takumi options beyond width, height, format, quality, stylesheets, emoji and fonts aren't passed through yet.

Resources

Thanks for reading.

This is me writing for you after a long time. If you have any questions or suggestions, please leave them in the comments and show some love ❤️.

With this Here are few things coming up in SvelteKit OG v5

  • Unified API for Sever import current u have to use takumi import if you need to use it. In the future, both the client and server APIs will look the same.
  • New docs with Nimbus, and SvelteKit OG might add support for Astro too (Who knows).
  • Performance improvement on server side.
  • A new version of Svelte-h2j with a new name and implementation. I have been thinking about this but it will take some time to work on how svelte compiler works and only if package size doesn't effect SvelteKit OG.

That's all. I will try to write more.

© 2023 EtherCorps. All rights reserved.