SvelteKit gives you two natural places for a screenshot: a +server.ts endpoint that returns the image, and a load function that builds an og:image URL before the page renders. This page builds both, then covers the part that changes with your adapter: how long a render may take on Node, Vercel and Cloudflare, and which code needs Node.js APIs. For SDK basics, see the Node.js screenshot API page.
Last tested: 2026-10-19 with Node 22.18, SvelteKit 3.1.0, Svelte 5.57.2, Vite 8.3.4,
@sveltejs/adapter-node6.0.0,renderscreenshot1.1.2, TypeScript 6.0.3 (svelte-kit sync && tsc --noEmitpassed) and vitest 5.0.5. The built Node server ran against a local stub of our API. We read the Vercel and Cloudflare adapter types (7.0.1 and 8.0.1) but did not deploy to either platform, and we did not call the live API.
Environment variables
SvelteKit 3 declares environment variables in src/env.ts, with an optional validator for each one:
// src/env.ts import { defineEnvVars } from '@sveltejs/kit/env'; const optional = (value: string | undefined) => value; export const variables = defineEnvVars({ RS_API_KEY: { description: 'rs_live_ key for server-side renders' }, RS_SIGNING_KEY: { description: 'rs_secret_ key, signs GET URLs' }, RS_PUBLIC_KEY_ID: { description: 'rs_pub_ key ID, goes into signed URLs' }, RS_API_BASE: { schema: optional, description: 'Only for tests against a stub' }, });
Variables are private unless you mark them public, so all four come from $app/env/private, a server-only module. In our build, vite build stopped with "Value is missing" until the three required variables were set. Set them in your CI build step as well as at runtime, or give them an optional schema like RS_API_BASE.
// src/lib/server/screenshots.ts import { RS_API_BASE, RS_API_KEY } from '$app/env/private'; import { Client } from 'renderscreenshot'; export const screenshots = new Client(RS_API_KEY, { baseUrl: RS_API_BASE, // unset in production timeout: 75_000, // above the API's 60-second maximum wait });
The #lib imports below are package.json subpath imports ("#lib/*": "./src/lib/*"). SvelteKit 3 deprecates its alias option and points to subpath imports instead.
A +server.ts image endpoint
// src/routes/og/[slug].webp/+server.ts import { error } from '@sveltejs/kit'; import { RenderScreenshotError, TakeOptions } from 'renderscreenshot'; import { screenshots } from '#lib/server/screenshots.ts'; import type { RequestHandler } from './$types'; const SITE = 'https://example.com'; // never url.origin: it comes from the Host header const SLUG = /^[a-z0-9-]{1,80}$/; export const GET: RequestHandler = async ({ params }) => { if (!SLUG.test(params.slug)) error(404, 'Not found'); try { const image = await screenshots.take( TakeOptions.url(`${SITE}/blog/${params.slug}`) .preset('og_card') .format('webp'), ); return new Response(new Uint8Array(image), { headers: { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=86400, s-maxage=604800', }, }); } catch (err) { if (!(err instanceof RenderScreenshotError)) throw err; console.error('og render failed', err.httpStatus, err.code); if (err.httpStatus === 429) { return new Response(null, { status: 503, headers: { 'Retry-After': String(err.retryAfter ?? 30) } }); } error(502, 'Render failed'); } };
Notes on the choices:
- The route ID carries the extension. SvelteKit matches
[slug].webpas a parameter followed by a literal.webp, so/og/hello-world.webpgivesparams.slug === 'hello-world'. Crawlers and chat apps get a URL that ends in the image type. - The slug pattern and the constant
SITEare the security boundary. Together they mean the endpoint can only capture your own blog./og/Bad_Slug.webpreturned 404 in our run, with no request to the stub. og_cardwith WebP. The preset sets 1200 by 630, andformat('webp')overrides its PNG default. The stub received{"preset":"og_card","output":{"format":"webp"}}, which is the documented nested form.s-maxagefor the CDN,max-agefor browsers. Each request that reaches the endpoint is a render and uses a credit. A CDN in front of it absorbs repeat fetches from crawlers.- A 429 becomes a 503 with
Retry-After. The rate limit is per account, shared by all your keys (rate limits). Retrying inside the request would hold a function open and add load. Theerror.codein the log maps to the error codes index.
Signed URLs from a load function
The endpoint above still puts your server in the path of every crawler fetch. A signed URL removes it: the page's load function signs a GET URL, and the crawler loads the image from the API. This signer uses Web Crypto, which every SvelteKit adapter target provides:
// src/lib/server/sign.ts import { RS_PUBLIC_KEY_ID, RS_SIGNING_KEY } from '$app/env/private'; const ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot'; // Form encoding, as the server does it: space -> "+", and !'()* are escaped too. const enc = (v: string) => encodeURIComponent(v) .replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) .replace(/%20/g, '+'); // Web Crypto HMAC-SHA256: runs on Node, Vercel, Netlify and Cloudflare without nodejs_compat. export async function signedScreenshotUrl( params: Record<string, string | number | boolean>, expiresAt: Date, ): Promise<string> { const fields: Record<string, string> = { key_id: RS_PUBLIC_KEY_ID }; for (const [k, v] of Object.entries(params)) fields[k] = String(v); fields.expires = String(Math.floor(expiresAt.getTime() / 1000)); const query = Object.entries(fields) .map(([k, v]) => `${enc(k)}=${enc(v)}`) .sort() .join('&'); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(RS_SIGNING_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(query)); const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join(''); return `${ENDPOINT}?${query}&signature=${hex}`; }
// src/routes/blog/[slug]/+page.server.ts import { error } from '@sveltejs/kit'; import { signedScreenshotUrl } from '#lib/server/sign.ts'; import type { PageServerLoad } from './$types'; const SITE = 'https://example.com'; const DAY = 86_400_000; const POSTS: Record<string, string> = { 'hello-world': 'Hello, world' }; export const load: PageServerLoad = async ({ params, setHeaders }) => { const title = POSTS[params.slug]; if (!title) error(404, 'Not found'); // Same URL all day; expiry 7 to 8 days ahead (the maximum is 30). const expires = new Date(Math.floor(Date.now() / DAY) * DAY + 8 * DAY); const ogImage = await signedScreenshotUrl( { url: `${SITE}/blog/${params.slug}`, preset: 'og_card', format: 'png' }, expires, ); setHeaders({ 'Cache-Control': 'public, max-age=3600' }); return { title, ogImage }; };
<!-- src/routes/blog/[slug]/+page.svelte --> <script lang="ts"> let { data } = $props(); </script> <svelte:head> <title>{data.title}</title> <meta property="og:title" content={data.title} /> <meta property="og:image" content={data.ogImage} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta name="twitter:card" content="summary_large_image" /> </svelte:head> <h1>{data.title}</h1>
Why the load function is in +page.server.ts and not +page.ts: a universal load also runs in the browser, and $app/env/private cannot be imported there, so the build would fail. The server load runs only on the server, and only the finished URL is serialized to the page. Svelte escapes the & characters in the attribute, which we saw in the rendered HTML.
The encoder escapes !'()* because the server encodes them and encodeURIComponent does not. Our first version skipped that step. It matched the SDK's generateUrl() for plain URLs, but a page URL with an apostrophe or a bracket would have failed with invalid_signature. The test below now checks a URL with those characters against the SDK. If your build prerenders this page, the expiry is fixed at build time, so rebuild at least every 30 days or keep the route dynamic.
Adapter notes: time limits and Node APIs
A render can take up to the API's 60-second maximum wait, plus network time. Every layer between the crawler and the SDK call must allow that:
| Adapter | What limits a slow render | What to set |
|---|---|---|
adapter-node |
Nothing in Node itself. A reverse proxy in front often does: nginx's proxy_read_timeout defaults to 60 seconds. |
Raise the proxy timeout above 75 seconds for /og/ |
adapter-vercel |
The function's maximum duration, with a ceiling that depends on your Vercel plan | export const config = { maxDuration: 90 }; in the +server.ts file |
adapter-cloudflare |
CPU time, not wall-clock time: waiting on fetch uses almost no CPU |
Nothing for the timeout. Add nodejs_compat if you use the SDK |
maxDuration is part of the route-level config that @sveltejs/adapter-vercel reads; check Vercel's current limits for your plan before you rely on 90 seconds.
On Cloudflare, Node APIs are the real issue. We read the SDK's build: renderscreenshot 1.1.2 imports createHmac from node:crypto and returns a Buffer, so a Worker needs the nodejs_compat compatibility flag to load it. The Web Crypto signer above needs neither, which is one reason this page uses it for the load path. If you do not want the flag at all, replace the SDK call in +server.ts with a plain fetch to POST /v1/screenshot, as the raw HTTP guide shows. The Cloudflare Workers integration page and the Vercel integration page cover each platform beyond SvelteKit.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
vite build stops with "Value is missing" |
A variable from src/env.ts is not set in the build step |
Set it in CI, or give it an optional schema |
The build fails because client code imports $app/env/private |
The signer is imported from a universal +page.ts |
Move the load function to +page.server.ts |
The image URL returns invalid_signature |
A parameter changed after signing, or an encoder skips !'()* |
Sign the exact parameters you send, with the encoder above |
| The endpoint answers 504 after about 60 seconds | A proxy or platform limit is shorter than the render | Raise it, as in the adapter table |
A Worker fails to load node:crypto |
nodejs_compat is off |
Enable it, or use fetch and the Web Crypto signer |
Test with vitest
// test/og.test.ts import { describe, expect, it, vi } from 'vitest'; import { Client, RenderScreenshotError, TakeOptions, type ErrorCode } from 'renderscreenshot'; vi.mock('$app/env/private', () => ({ RS_API_KEY: 'rs_live_test', RS_API_BASE: undefined, RS_SIGNING_KEY: 'rs_secret_test', RS_PUBLIC_KEY_ID: 'rs_pub_test', })); const { screenshots } = await import('#lib/server/screenshots.ts'); const { GET } = await import('../src/routes/og/[slug].webp/+server.ts'); const { signedScreenshotUrl } = await import('#lib/server/sign.ts'); const call = (slug: string) => GET({ params: { slug } } as Parameters<typeof GET>[0]); describe('GET /og/[slug].webp', () => { it('returns the image with cache headers', async () => { vi.spyOn(screenshots, 'take').mockResolvedValue(Buffer.from('RIFF')); const res = await call('hello-world'); expect(res.headers.get('content-type')).toBe('image/webp'); expect(await res.text()).toBe('RIFF'); }); it('turns a 429 into a 503 with Retry-After', async () => { vi.spyOn(screenshots, 'take').mockRejectedValue( // The API sends too_many_requests; the SDK's ErrorCode type does not list it. new RenderScreenshotError(429, 'too_many_requests' as ErrorCode, 'slow down', 12), ); vi.spyOn(console, 'error').mockImplementation(() => {}); const res = await call('hello-world'); expect(res.status).toBe(503); expect(res.headers.get('retry-after')).toBe('12'); }); it('rejects a bad slug without a render', async () => { const take = vi.spyOn(screenshots, 'take'); await expect(call('../admin')).rejects.toMatchObject({ status: 404 }); expect(take).not.toHaveBeenCalled(); }); }); it('signs the same URL as the Node SDK', async () => { const expires = new Date(Date.UTC(2026, 9, 26)); const ours = await signedScreenshotUrl({ url: "https://example.com/it's (new)!", preset: 'og_card' }, expires); const sdk = new Client('unused', { signingKey: 'rs_secret_test', publicKeyId: 'rs_pub_test' }) .generateUrl(TakeOptions.url("https://example.com/it's (new)!").preset('og_card'), expires); expect(ours).toBe(sdk); });
vi.mock replaces the virtual env module, so the tests need no real keys. error(404) in SvelteKit throws an object with a status field, which is why the bad-slug test expects a rejection rather than a response. For contract tests against a fake HTTP server, see the SDK testing guide.
Get a free API key, add RS_API_KEY, RS_SIGNING_KEY and RS_PUBLIC_KEY_ID to your environment, and open /og/<slug>.webp. The free plan includes 150 credits every month.