Dynamic SVG Cover Images: Enhancing Your SvelteKit Blog
A practical guide to programmatically generated cover images for your SvelteKit blog: SVG on the page, rendered PNG for social link previews, with no external image creation tools
, filed under sveltekit, svg, open-graph, performance, design
In the world of technical blogging, visual consistency and brand identity play crucial roles in establishing credibility and recognition. While many developers resort to creating custom images for each blog post using external design tools, there’s a more elegant, programmatic solution available: dynamically generated SVG cover images. This approach not only ensures visual consistency across your content but also eliminates the need for managing and storing individual image files.
The Problem with Traditional Cover Images
Traditional approaches to blog post cover images typically involve creating each image manually using design tools, storing them in your project or on a CDN, and referencing them in your posts’ frontmatter. This workflow introduces several challenges:
- Design consistency: Maintaining visual consistency across dozens of manually created images is difficult
- Storage overhead: Each image adds to your repository size or CDN costs
- Workflow friction: Creating images becomes a separate task in your content creation process
- Maintenance burden: Updating your visual style requires recreating all existing images
By generating SVG cover images programmatically, we can address these challenges while gaining additional benefits like perfect scaling, theme adaptation, and automated generation.
Implementing Dynamic SVG Covers in SvelteKit
Let’s walk through implementing a system for dynamic cover images in a SvelteKit blog. We’ll create an SVG component for in-page display and a server endpoint that renders the same design to a PNG for Open Graph images.
Why two formats? SVG is perfect on the page: it scales to any screen
and can pick up your theme’s CSS variables. Social platforms won’t
show it, though. Facebook, LinkedIn and X all expect a raster image
(PNG, JPEG, WebP or GIF) for link previews, and an SVG og:image gets
you a blank card. So the page gets the SVG, and the link preview gets
a PNG rendered from the same design.
Step 1: Sharing the Pattern Logic
The pattern is used in two places, so put it in its own module. The colour is a parameter because the in-page cover can use CSS variables but the PNG renderer can’t:
// src/lib/cover/pattern.ts
// Generate a deterministic pattern based on the slug
export const get_pattern = (slug: string) => {
const hash = slug
.split('')
.reduce((acc, char) => char.charCodeAt(0) + acc, 0);
const pattern_type = hash % 4; // 4 different pattern types
switch (pattern_type) {
case 0:
return 'dots-grid';
case 1:
return 'dots-scattered';
case 2:
return 'lines-horizontal';
default:
return 'lines-grid';
}
};
// Generate pattern elements based on the pattern type
export const generate_pattern_elements = (
pattern: string,
slug: string,
colour: string,
) => {
switch (pattern) {
case 'dots-grid':
// Create a grid of small dots
return Array(300)
.fill(0)
.map((_, i) => {
const x = (i % 30) * 40 + 20;
const y = Math.floor(i / 30) * 20 + 10;
return `<circle cx="${x}" cy="${y}" r="4" fill="${colour}" opacity="0.2" />`;
})
.join('');
case 'dots-scattered':
// Create scattered dots of varying sizes
return Array(200)
.fill(0)
.map((_, i) => {
// Use hash of index + slug to create deterministic but scattered positions
const hash =
(i + slug.length) *
(slug.charCodeAt(i % slug.length) || 13);
const x = hash % 1200;
const y = (hash * 13) % 630;
const size = (hash % 5) + 2; // Sizes between 2-6px
const opacity = 0.1 + (hash % 15) / 100; // Opacity between 0.1-0.25
return `<circle cx="${x}" cy="${y}" r="${size}" fill="${colour}" opacity="${opacity}" />`;
})
.join('');
case 'lines-horizontal':
// Create horizontal lines
return Array(30)
.fill(0)
.map((_, i) => {
const y = i * 22;
const opacity = 0.1 + (i % 3) * 0.05;
return `<line x1="0" y1="${y}" x2="1200" y2="${y}" stroke="${colour}" stroke-width="1" opacity="${opacity}" />`;
})
.join('');
default: {
// Create a grid of lines
const vertical = Array(40)
.fill(0)
.map((_, i) => {
const x = i * 30;
return `<line x1="${x}" y1="0" x2="${x}" y2="630" stroke="${colour}" stroke-width="1" opacity="0.1" />`;
})
.join('');
const horizontal = Array(30)
.fill(0)
.map((_, i) => {
const y = i * 22;
return `<line x1="0" y1="${y}" x2="1200" y2="${y}" stroke="${colour}" stroke-width="1" opacity="0.1" />`;
})
.join('');
return vertical + horizontal;
}
}
};Step 2: Creating the SVG Component
Now the in-page cover. It generates a visually appealing cover based on the post’s title and slug, and uses CSS variables so it follows your theme, including dark mode:
<!-- src/lib/components/PostCover.svelte -->
<script lang="ts">
import {
generate_pattern_elements,
get_pattern,
} from '$lib/cover/pattern';
let { title, slug }: { title: string; slug: string } = $props();
const pattern_elements = $derived(
generate_pattern_elements(
get_pattern(slug),
slug,
'var(--color-secondary)',
),
);
</script>
<svg
viewBox="0 0 1200 630"
width="100%"
height="auto"
preserveAspectRatio="xMidYMid meet"
>
<!-- Background -->
<rect width="100%" height="100%" fill="var(--color-primary)" />
<!-- Pattern -->
<g>
{@html pattern_elements}
</g>
<!-- Title with text wrapping -->
<foreignObject x="60" y="120" width="1080" height="400">
<div
xmlns="http://www.w3.org/1999/xhtml"
style="font-family: system-ui, sans-serif; color: var(--color-primary-content); font-weight: bold; font-size: 80px; line-height: 1.3; text-shadow: 0 4px 8px rgba(0,0,0,0.3); overflow-wrap: break-word; word-wrap: break-word; hyphens: auto; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; text-align: center;"
>
{title}
</div>
</foreignObject>
</svg>This component generates a unique but deterministic pattern based on
the post’s slug, ensuring that each post has a distinct visual
identity while maintaining overall design consistency. The pattern
markup comes from our own module, not user input, so {@html} is safe
here. The title goes through Svelte’s normal text escaping.
Step 3: Rendering the Open Graph Image as a PNG
Next, a server endpoint that renders the same design to a PNG for
social previews. This example uses @resvg/resvg-js to rasterise the
SVG:
pnpm add @resvg/resvg-jsThe renderer has a few limits that shape the code:
- It doesn’t support
foreignObject, so the title is wrapped into<tspan>lines by hand. - It doesn’t resolve CSS variables, so the colours are plain values. Use the equivalents of your theme colours.
- It only draws fonts it can load. Most servers have few or none, so ship a font file with your app.
The post title ends up inside SVG markup, so escape it. A title with
an & or a < in it would otherwise break the image, or let someone
inject markup into it.
// src/routes/api/og-image/[slug].png/+server.ts
import { error } from '@sveltejs/kit';
import { Resvg } from '@resvg/resvg-js';
import {
generate_pattern_elements,
get_pattern,
} from '$lib/cover/pattern';
import type { RequestHandler } from './$types';
interface PostMetadata {
title: string;
published: boolean;
}
const posts = import.meta.glob<{ metadata: PostMetadata }>(
'/src/posts/*.md',
{ eager: true },
);
const escape_xml = (value: string) =>
value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// resvg can't wrap text for us, so split the title into lines
const wrap_title = (title: string, max_chars = 24, max_lines = 4) => {
const lines: string[] = [];
let line = '';
for (const word of title.split(/\s+/)) {
const next = line ? `${line} ${word}` : word;
if (next.length > max_chars && line) {
lines.push(line);
line = word;
} else {
line = next;
}
}
if (line) lines.push(line);
if (lines.length > max_lines) {
lines.length = max_lines;
lines[max_lines - 1] += '…';
}
return lines;
};
export const GET: RequestHandler = ({ params }) => {
const post = posts[`/src/posts/${params.slug}.md`];
// A missing or unpublished post is a 404, not a fallback image
if (!post?.metadata.published) {
error(404, 'Post not found');
}
const lines = wrap_title(post.metadata.title);
const line_height = 96;
const first_line_y = 315 - ((lines.length - 1) * line_height) / 2;
const title = lines
.map(
(line, i) =>
`<tspan x="600" y="${first_line_y + i * line_height}">${escape_xml(line)}</tspan>`,
)
.join('');
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
<rect width="100%" height="100%" fill="#0098ff" />
<g>
${generate_pattern_elements(get_pattern(params.slug), params.slug, '#00b5eb')}
</g>
<text font-family="Inter" font-size="80" font-weight="700" fill="#f6f9fb" text-anchor="middle" dominant-baseline="middle">
${title}
</text>
</svg>
`;
const png = new Resvg(svg, {
font: {
fontFiles: ['static/fonts/Inter-Bold.ttf'],
loadSystemFonts: false,
defaultFontFamily: 'Inter',
},
})
.render()
.asPng();
return new Response(png, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800',
},
});
};There’s no try/catch here, and that’s on purpose. The earlier version of this endpoint wrapped everything in one, so a missing post came back as a generic “Blog Post” image with a 200. Crawlers then cached a placeholder for a URL that shouldn’t exist. Now a missing post returns a 404, and a real rendering failure surfaces as a 500 you can see in your logs.
The [slug].png directory name means the URL ends in .png, and params.slug arrives without the extension. Some crawlers look at the
extension when deciding how to treat an image URL, so it’s worth
having. It also saves you the param matcher and the second route that
re-exported the handler.
The path to the font file is relative to the directory the server runs
from. Adjust it to wherever your deployment puts static/.
Step 4: Using the Dynamic Cover in Your Blog Posts
Now, update your blog post page to use the dynamic cover. This example
uses svead for SEO metadata, but I’ll also show you how to implement
this without any external packages:
<!-- src/routes/posts/[slug]/+page.svelte -->
<script lang="ts">
import { create_blog_schema, create_seo_config } from '$lib/seo';
import PostCover from '$lib/components/PostCover.svelte';
import { Head, SchemaOrg } from 'svead';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Use the dynamic OG image endpoint for all posts
const og_image = $derived(
`/api/og-image/${data.frontmatter.slug}.png`,
);
const seo_config = $derived(
create_seo_config({
title: data.frontmatter.title,
description: data.frontmatter.description,
slug: `posts/${data.frontmatter.slug}`,
open_graph_image: og_image,
}),
);
const schema = $derived(
create_blog_schema(
data.frontmatter.title,
data.frontmatter.description,
data.frontmatter.slug,
data.frontmatter.date,
data.frontmatter.updated,
og_image,
),
);
</script>
<Head {seo_config} />
<SchemaOrg {schema} />
<article class="all-prose container mx-auto max-w-3xl flex-grow px-4">
<!-- Add the cover image at the top of the post -->
<div class="mb-8 overflow-hidden rounded-xl shadow-xl">
<PostCover
title={data.frontmatter.title}
slug={data.frontmatter.slug}
/>
</div>
<h1 class="mt-12 text-primary">
{data?.frontmatter?.title || 'Untitled Post'}
</h1>
<!-- Rest of your post template... -->
</article>Alternative: Without External SEO Packages
If you prefer not to use external packages like svead, you can
implement the SEO metadata directly in your layout or page component:
<!-- src/routes/posts/[slug]/+page.svelte (without svead) -->
<script lang="ts">
import PostCover from '$lib/components/PostCover.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Use the dynamic OG image endpoint for all posts
const site_url = 'https://yourdomain.com';
const post_url = $derived(
`${site_url}/posts/${data.frontmatter.slug}`,
);
const og_image = $derived(
`${site_url}/api/og-image/${data.frontmatter.slug}.png`,
);
</script>
<svelte:head>
<title>{data.frontmatter.title}</title>
<meta name="description" content={data.frontmatter.description} />
<!-- Open Graph / Facebook / LinkedIn -->
<meta property="og:type" content="article" />
<meta property="og:url" content={post_url} />
<meta property="og:title" content={data.frontmatter.title} />
<meta
property="og:description"
content={data.frontmatter.description}
/>
<meta property="og:image" content={og_image} />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- X / Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={data.frontmatter.title} />
<meta
name="twitter:description"
content={data.frontmatter.description}
/>
<meta name="twitter:image" content={og_image} />
</svelte:head>
<article class="all-prose container mx-auto max-w-3xl flex-grow px-4">
<!-- Add the cover image at the top of the post -->
<div class="mb-8 overflow-hidden rounded-xl shadow-xl">
<PostCover
title={data.frontmatter.title}
slug={data.frontmatter.slug}
/>
</div>
<h1 class="mt-12 text-primary">
{data?.frontmatter?.title || 'Untitled Post'}
</h1>
<!-- Rest of your post template... -->
</article>This approach uses SvelteKit’s built-in <svelte:head> component to
add the necessary meta tags for SEO and social sharing. It’s more
verbose than using a package like svead, but it gives you complete
control over your metadata without adding dependencies. Note that og:image needs a full URL, not a path: crawlers don’t resolve
relative image URLs.
You could also create your own reusable SEO component:
<!-- src/lib/components/Seo.svelte -->
<script lang="ts">
interface Props {
title: string;
description: string;
url: string;
image: string;
site_name?: string;
}
let {
title,
description,
url,
image,
site_name = 'Your Site Name',
}: Props = $props();
const full_url = $derived(
url.startsWith('http') ? url : `https://yourdomain.com${url}`,
);
const full_image_url = $derived(
image.startsWith('http')
? image
: `https://yourdomain.com${image}`,
);
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
<!-- Open Graph / Facebook / LinkedIn -->
<meta property="og:type" content="article" />
<meta property="og:url" content={full_url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={full_image_url} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:site_name" content={site_name} />
<!-- X / Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={full_image_url} />
</svelte:head>Then use it in your page:
<script lang="ts">
import Seo from '$lib/components/Seo.svelte';
// ... other imports and code
</script>
<Seo
title={data.frontmatter.title}
description={data.frontmatter.description}
url={`/posts/${data.frontmatter.slug}`}
image={`/api/og-image/${data.frontmatter.slug}.png`}
/>
<!-- Rest of your component -->This gives you the benefits of reusability without external dependencies.
Technical Benefits of Dynamic Covers
This approach offers several technical advantages:
- Performance: The on-page SVG is typically smaller than a raster image and scales perfectly to any device. The PNG is only rendered when a crawler asks for it, and then cached
- Theme compatibility: By using CSS variables, the in-page covers can adapt to your site’s theme, including dark mode
- Consistency: All covers follow the same design language while maintaining unique visual identities
- Automation: No manual image creation process is needed when publishing new content
- Maintainability: Design changes can be implemented across all covers by updating one shared pattern module
Extending the System
Once you have the basic system in place, there are several ways to extend it:
Dynamic Color Schemes
You can generate color schemes based on the post’s category or tags:
const get_color_scheme = (tags: string[]) => {
if (tags.includes('performance')) {
return {
primary: 'oklch(65% 0.24 130)', // Green hue
secondary: 'oklch(70% 0.18 150)',
};
}
if (tags.includes('security')) {
return {
primary: 'oklch(65% 0.24 30)', // Red hue
secondary: 'oklch(70% 0.18 40)',
};
}
// Default blue scheme
return {
primary: 'oklch(65% 0.24 240)',
secondary: 'oklch(70% 0.18 220)',
};
};Additional Visual Elements
You could add category icons, reading time indicators, or other metadata to the cover:
<!-- Category icon -->
{#if category === 'performance'}
<svg x="1080" y="40" width="80" height="80">
<path d="..." fill="var(--color-secondary)" />
</svg>
{/if}
<!-- Reading time -->
<text
x="60"
y="580"
font-size="24"
fill="var(--color-primary-content)"
opacity="0.8"
>
{reading_time} min read
</text>Animation
For the in-page component (not the OG image), you could add subtle animations to make the cover more engaging:
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.pattern-element {
animation: fadeIn 1.5s ease-out;
}
.title-text {
animation: fadeIn 2s ease-out;
}
</style>Conclusion
Dynamic SVG cover images represent a perfect intersection of technical elegance and practical utility for SvelteKit blogs. By generating these images programmatically, you eliminate the need for external design tools while ensuring visual consistency across your content. Rendering the same design to PNG for link previews means the covers actually show up when your posts are shared.
This approach aligns perfectly with SvelteKit’s philosophy of building efficient, maintainable web applications. The system is lightweight, performant, and adaptable to your specific design needs. Most importantly, it removes a significant friction point in the content creation process, allowing you to focus on writing great technical content rather than designing individual cover images.
By implementing this system, you’re not just solving a practical problem—you’re embracing a more systematic, programmatic approach to design that scales with your content library and adapts to your evolving brand identity.