Menu
sveltekittutorialscreenshot-apiog-imagesvelte

SvelteKit OG Images and Screenshots in 10 Minutes — Complete Tutorial

SnapSharp Team·April 26, 2026·5 min read

SvelteKit gives you load functions, server endpoints, and form actions — three places where it's natural to call an external API and pipe the result into your page. Dynamic OG images and live screenshots fit this model perfectly. The pattern: a +server.ts endpoint hits SnapSharp, returns the binary, and your page references it via a clean URL.

In this tutorial we'll build a SvelteKit blog with per-post OG images, plus a /preview/[domain] route that shows live screenshots of any URL. By the end you'll know how to wire this up cleanly across adapter-node, adapter-vercel, and adapter-cloudflare.

Prerequisites

  • Node.js 20+ and pnpm.
  • A SvelteKit project (pnpm create svelte@latest) or an existing one.
  • A free SnapSharp API key from snapsharp.dev/sign-up.
  • Familiarity with +page.server.ts and +server.ts conventions.

Step 1: install and configure

The SnapSharp Node SDK works in any SvelteKit adapter. It has zero runtime dependencies, so it fits inside Edge bundles too.

pnpm add @snapsharp/sdk

Add your API key to .env:

SNAPSHARP_API_KEY=sk_live_YOUR_API_KEY

Important: SvelteKit reads .env automatically and exposes private vars only to server code. Use $env/static/private to import them — it's compile-time checked.

// src/lib/server/snapsharp.ts
import { SnapSharp } from '@snapsharp/sdk';
import { SNAPSHARP_API_KEY } from '$env/static/private';

if (!SNAPSHARP_API_KEY) {
  throw new Error('SNAPSHARP_API_KEY is required');
}

export const snap = new SnapSharp(SNAPSHARP_API_KEY);

The server directory naming is a SvelteKit convention — anything inside $lib/server is guaranteed not to leak into the client bundle. Treat it like a vault.

Step 2: dynamic OG images via +server endpoints

SvelteKit doesn't have an opengraph-image convention like Next.js, but the +server.ts pattern is just as clean. Create src/routes/blog/[slug]/og.png/+server.ts:

import type { RequestHandler } from './$types';
import { snap } from '$lib/server/snapsharp';
import { getPost } from '$lib/server/posts';
import { error } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ params, setHeaders }) => {
  const post = await getPost(params.slug);
  if (!post) throw error(404, 'Post not found');

  const image = await snap.ogImage({
    template: 'blog-post',
    variables: {
      title: post.title,
      author: post.author,
      date: post.date,
      tag: post.tags?.[0] ?? '',
    },
    width: 1200,
    height: 630,
  });

  setHeaders({
    'Content-Type': 'image/png',
    'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
  });

  return new Response(image);
};

Now reference it from your page's head. In src/routes/blog/[slug]/+page.svelte:

<script lang="ts">
  import { page } from '$app/stores';
  import type { PageData } from './$types';

  export let data: PageData;
  $: ogUrl = `${$page.url.origin}/blog/${$page.params.slug}/og.png`;
</script>

<svelte:head>
  <title>{data.post.title}</title>
  <meta property="og:title" content={data.post.title} />
  <meta property="og:description" content={data.post.excerpt} />
  <meta property="og:image" content={ogUrl} />
  <meta property="og:image:width" content="1200" />
  <meta property="og:image:height" content="630" />
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:image" content={ogUrl} />
</svelte:head>

<article>
  <h1>{data.post.title}</h1>
  <!-- ... -->
</article>

When LinkedIn or Twitter scrapes your post URL, it fetches og.png, which proxies to SnapSharp, which returns a designed image with the post's title and metadata baked in. See OG image best practices for what makes a card actually click-through-worthy.

Step 3: live screenshots in load functions

For a "site preview" feature, you want screenshots fetched at request time and rendered with the page. SvelteKit's +page.server.ts load is the right tool — it runs only on the server, has access to private env vars, and streams JSON down to the client.

// src/routes/preview/[domain]/+page.server.ts
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';

export const load: PageServerLoad = async ({ params, fetch }) => {
  const domain = params.domain;
  if (!/^[a-z0-9.-]+$/i.test(domain)) {
    throw error(400, 'Invalid domain');
  }

  const url = `https://${domain}`;
  const screenshotUrl = `/api/screenshot?url=${encodeURIComponent(url)}`;

  return {
    domain,
    url,
    screenshotUrl,
  };
};

We don't fetch the binary in load — we'd be pushing megabytes of base64 over the wire to the client. Instead we hand back a URL pointing to a +server.ts endpoint. The browser fetches that endpoint directly when rendering the <img> tag.

The +server.ts:

// src/routes/api/screenshot/+server.ts
import type { RequestHandler } from './$types';
import { snap } from '$lib/server/snapsharp';
import { error } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ url, setHeaders }) => {
  const target = url.searchParams.get('url');
  if (!target) throw error(400, 'url required');

  try {
    const image = await snap.screenshot(target, {
      width: 1280,
      height: 720,
      format: 'png',
      blockAds: true,
    });

    setHeaders({
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
    });

    return new Response(image);
  } catch (err) {
    console.error('Screenshot failed:', err);
    throw error(502, 'capture failed');
  }
};

The page component just consumes the URL:

<!-- src/routes/preview/[domain]/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  export let data: PageData;
</script>

<main>
  <h1>{data.domain}</h1>
  <a href={data.url}>{data.url}</a>
  <img
    src={data.screenshotUrl}
    alt={`Screenshot of ${data.domain}`}
    width="1280"
    height="720"
    loading="lazy"
  />
</main>

Step 4: production patterns

Caching with the platform CDN

The Cache-Control: public, max-age=86400, stale-while-revalidate=604800 header is your friend. On Vercel, Cloudflare, and Netlify, this tells the edge to:

  1. Serve the cached image for 24 hours without hitting your function.
  2. Serve stale images for up to 7 days while quietly fetching a fresh one in the background.

For most preview UIs, this means SnapSharp gets called once per URL per day, regardless of traffic. Costs stay flat as you scale.

Validating user input (SSRF protection)

If your screenshot endpoint accepts arbitrary URLs from users, validate them. SnapSharp blocks internal IPs server-side, but you should also reject obviously malformed input early:

function isValidPublicUrl(input: string): boolean {
  try {
    const u = new URL(input);
    if (!['http:', 'https:'].includes(u.protocol)) return false;
    if (u.hostname === 'localhost') return false;
    if (/^(127\.|10\.|192\.168\.|169\.254\.)/.test(u.hostname)) return false;
    return true;
  } catch {
    return false;
  }
}

Put this in $lib/server/url.ts and call it before every screenshot.

Rate limiting per IP

For public preview endpoints, add a simple in-memory rate limiter to avoid people draining your quota:

const buckets = new Map<string, { count: number; reset: number }>();

export function checkRate(ip: string, limit = 10, windowMs = 60_000): boolean {
  const now = Date.now();
  const bucket = buckets.get(ip);
  if (!bucket || bucket.reset < now) {
    buckets.set(ip, { count: 1, reset: now + windowMs });
    return true;
  }
  if (bucket.count >= limit) return false;
  bucket.count++;
  return true;
}

In a +server.ts:

import { getClientAddress } from '@sveltejs/kit';

if (!checkRate(getClientAddress())) {
  throw error(429, 'rate limit exceeded');
}

For production traffic across multiple instances, swap the Map for Redis.

Handling SnapSharp errors

The SDK throws SnapSharpError with .status and .code. Map them to user-facing messages:

import { SnapSharpError } from '@snapsharp/sdk';

try {
  await snap.screenshot(url);
} catch (err) {
  if (err instanceof SnapSharpError) {
    if (err.status === 429) throw error(429, 'API rate limited, try again');
    if (err.status === 403) throw error(402, 'plan required for this feature');
  }
  throw error(502, 'screenshot failed');
}

Step 5: deploying

adapter-node

Default for self-hosted SvelteKit. Set SNAPSHARP_API_KEY in your environment manager (PM2, systemd, Docker). No special config — endpoints work out of the box.

adapter-vercel

Add SNAPSHARP_API_KEY in your Vercel dashboard → Environment Variables. To pin function region close to SnapSharp (EU), set regions: ['fra1'] per route or globally in svelte.config.js:

import vercel from '@sveltejs/adapter-vercel';

export default {
  kit: {
    adapter: vercel({ regions: ['fra1'] }),
  },
};

adapter-cloudflare

Cloudflare Workers run V8, not Node, so the SDK's fetch-based code path is fine. Set the API key as a Worker secret:

wrangler secret put SNAPSHARP_API_KEY

Then access via platform.env.SNAPSHARP_API_KEY from +server.ts instead of $env/static/private. See the Cloudflare Workers tutorial for the full pattern.

Common pitfalls

Pitfall 1: forgetting setHeaders. Without Cache-Control, every visitor triggers a fresh SnapSharp request. Set it on every binary response.

Pitfall 2: using fetch in load with the API key. +page.server.ts is server-only, but +page.ts runs on both server and client. If you accidentally put SnapSharp logic in the wrong file, your API key ends up in the JS bundle. Always keep API calls in *.server.ts files or $lib/server/*.

Pitfall 3: large pages and timeouts. Full-page screenshots of long pages can take 10+ seconds. SvelteKit's adapter timeout (10s on Vercel free tier) will cut you off. Pass full_page_max_height to cap height, or use /v1/async/screenshot for jobs that exceed the timeout.

Pitfall 4: forgetting <svelte:head>. OG meta tags only work if they're in the rendered HTML. Client-only injection won't help — social scrapers don't run JS. Use <svelte:head> on the page that owns the OG image.

Pitfall 5: relying on SSR for binary endpoints. +server.ts is correct. Don't try to return images from +page.server.ts load — that function returns JSON for the client.

Final code

The integration is three files:

  • src/lib/server/snapsharp.ts — typed client.
  • src/routes/api/screenshot/+server.ts — proxied capture endpoint.
  • src/routes/blog/[slug]/og.png/+server.ts — per-post OG.

Around 60 lines total. Everything else is page templating.

Conclusion

SvelteKit's separation of +server.ts (binary endpoints), +page.server.ts (data loading), and +page.svelte (rendering) makes screenshot integration crisp. Each file does one thing. Pair this with adapter-vercel or adapter-cloudflare and you get edge-cached OG images with no Chromium binaries in your bundle.

Next steps: generate custom OG templates, explore the OG image API, or look at Astro content collection thumbnails for a similar pattern.


Related: OG image best practices · Pricing · Screenshot API comparison 2026

SvelteKit OG Images and Screenshots in 10 Minutes — Complete Tutorial — SnapSharp Blog