Give every page of your SvelteKit app its own Open Graph image with one command. The generator writes a share card as a Svelte component, src/lib/og/card.svelte, with an ordinary <style> block you can redesign, and a route that serves it as a 1200 × 630 PNG at /og/<path>.png. Each card takes its title and description from the page's own meta tags, so a new page needs no wiring. On a static site the cards are written as files during the build; on a server they are rendered on request. A page that sets its own image keeps it.
<!--
The share card: what a link to this site looks like in a chat, a feed or a
search result. It is rendered to a 1200x630 PNG by `src/lib/server/og.ts`,
never in a browser, so it is plain markup and CSS and has no interactivity.
Lay it out with flexbox. Tailwind classes and `app.css` do not apply here;
only this file's <style> block reaches the renderer.
-->
<svelte:options css="injected" />
<script lang="ts">
let {
title,
description,
siteName,
accent = "#4f46e5",
}: {
title: string;
description?: string;
siteName: string;
accent?: string;
} = $props();
// A long title steps down in size so it stays within three lines.
const titleSize = $derived(
title.length > 70 ? 52 : title.length > 40 ? 64 : 76,
);
</script>
<div class="card" style:--accent={accent}>
<div class="bar"></div>
<div class="body">
{#if title !== siteName}<div class="site">{siteName}</div>{/if}
<div class="title" style:font-size="{titleSize}px">{title}</div>
{#if description}<div class="description">{description}</div>{/if}
</div>
</div>
<style>
.card {
display: flex;
width: 1200px;
height: 630px;
background: linear-gradient(135deg, #0b1020, #1b2140);
color: white;
}
.bar {
width: 16px;
background: var(--accent);
}
.body {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: 24px;
padding: 72px;
}
.site {
font-size: 28px;
opacity: 0.7;
}
.title {
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.02em;
}
.description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
font-size: 32px;
line-height: 1.3;
opacity: 0.8;
}
</style>
import type { MetaTagsProps } from "svelte-meta-tags";
import { site } from "#lib/site.js";
/** Where a page's share card is served: `/` is `/og/index.png`, `/about` is `/og/about.png`. */
export function cardPath(pathname: string): string {
const path = pathname.replace(/\/+$/, "");
return `/og${path || "/index"}.png`;
}
/**
* Points a page's Open Graph image at its generated share card.
*
* A page that sets its own image keeps it: only the site-wide default
* (`/og.jpg`) or a missing image is replaced. The `image_src` link is how
* prerendering finds the card, since it only follows links on its own origin
* and the Open Graph image is an absolute URL on `site.url`.
*/
export function withCard(
tags: MetaTagsProps,
url: Pick<URL, "pathname">,
): MetaTagsProps {
const image = tags.openGraph?.images?.[0];
if (image && !image.url.endsWith("/og.jpg")) return tags;
const path = cardPath(url.pathname);
return {
...tags,
openGraph: {
...tags.openGraph,
images: [
{ url: `${site.url}${path}`, alt: site.name, width: 1200, height: 630 },
],
},
twitter: { cardType: "summary_large_image", ...tags.twitter },
additionalLinkTags: [
...(tags.additionalLinkTags ?? []),
{ rel: "image_src", href: path },
],
};
}
import type { ComponentProps } from "svelte";
import { render } from "svelte/server";
import { Renderer } from "@takumi-rs/core";
import Card from "#lib/og/card.svelte";
/**
* One renderer for the life of the server. It draws text in its built-in font
* (Geist). To use another, register the font file once, here, and name its
* family in the card's CSS:
*
* import font from './my-font.woff2?inline';
* await renderer.registerFont(Buffer.from(font.split(',')[1], 'base64'));
*/
const renderer = new Renderer();
/** Renders the share card to a 1200x630 PNG. */
export async function renderCard(props: ComponentProps<typeof Card>) {
const { head, body } = render(Card, { props });
const css = [...head.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map(
(match) => match[1],
);
return renderer.render(body, {
width: 1200,
height: 630,
format: "png",
css,
});
}
import { renderCard } from "#lib/server/og.js";
import { site } from "#lib/site.js";
import type { RequestHandler } from "./$types";
/**
* A share card for every page, at the page's own path: `/og/about.png` is the
* card for `/about`, and `/og/index.png` the one for `/`.
*
* Prerendered pages get their card written as a file at build time; on a
* server the rest are rendered on request.
*/
export const prerender = "auto";
const ENTITIES: Record<string, string> = {
"&": "&",
""": '"',
"'": "'",
"<": "<",
">": ">",
};
/** Drops the ` | Site name` the root layout's title template adds. */
function withoutSiteName(title: string | undefined) {
if (!title?.endsWith(site.name)) return title;
return (
title.slice(0, -site.name.length).replace(/\s*[|·–—-]\s*$/, "") || title
);
}
export const GET: RequestHandler = async ({ params, fetch }) => {
// The page itself says what its card reads: whatever it set as its title
// and description is in the meta tags it renders.
// Redirects are not followed: a page behind sign-in has no card, where
// following would hand back the sign-in page's.
const page = await fetch(params.path === "index" ? "/" : `/${params.path}`, {
redirect: "manual",
});
if (!page.ok) return new Response("Not found", { status: 404 });
const html = await page.text();
const meta = (property: string) =>
html
.match(new RegExp(`<meta property="${property}" content="([^"]*)"`))?.[1]
?.replace(/&(amp|quot|#39|lt|gt);/g, (entity) => ENTITIES[entity]);
const png = await renderCard({
title: withoutSiteName(meta("og:title")) || site.name,
description: meta("og:description"),
siteName: site.name,
});
return new Response(png, {
headers: {
"content-type": "image/png",
"cache-control": "public, max-age=86400",
},
});
};
<script lang="ts">
import "../app.css";
import favicon from "#lib/assets/favicon.svg";
import { site } from "#lib/site.js";
import { ModeWatcher } from "mode-watcher";
import { getFlash } from "sveltekit-flash-message";
import { toast } from "svelte-sonner";
import { page } from "$app/state";
import { Toaster } from "#lib/components/ui/sonner/index.js";
import { MetaTags, deepMerge } from "svelte-meta-tags";
import type { LayoutProps } from "./$types";
import { withCard } from "#lib/og/index.js";
let { data, children }: LayoutProps = $props();
const flash = getFlash(page);
$effect(() => {
if (!$flash || $flash.type !== "toast") {
return;
}
toast.message($flash.message);
$flash = undefined;
});
let metaTags = $derived(
withCard(deepMerge(data.baseMetaTags, page.data.pageMetaTags), page.url),
);
</script>
<svelte:head>
<title>{site.name}</title>
<link rel="icon" href={favicon} />
</svelte:head>
<MetaTags {...metaTags} />
<ModeWatcher />
<Toaster />
{@render children?.()}