Nuxt 3 ships with Nitro — a server engine that gives you server/api, server/routes, and a built-in cache layer. That's three good homes for screenshot integration. In this tutorial we'll build a Nuxt 3 app that generates dynamic OG images for content pages and exposes a /api/screenshot route for arbitrary URL captures.
The pattern works on every Nitro preset: Node server, Vercel, Netlify, Cloudflare Pages, AWS Lambda. SnapSharp's HTTP API needs nothing more than fetch.
Prerequisites
- Node.js 20+ and pnpm.
- A Nuxt 3 app (
pnpm dlx nuxi@latest init my-app). - A free SnapSharp API key from snapsharp.dev/sign-up.
- Familiarity with
useFetch,defineEventHandler, anduseRuntimeConfig.
Step 1: install and configure runtime config
pnpm add @snapsharp/sdkNuxt's runtime config is the canonical place for env-driven secrets. Edit nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
snapsharpApiKey: '', // overridden by NUXT_SNAPSHARP_API_KEY env var
public: {
siteUrl: 'https://yourdomain.com', // safe to expose
},
},
});Set the env var:
NUXT_SNAPSHARP_API_KEY=sk_live_YOUR_API_KEYNuxt automatically maps NUXT_* env vars to runtime config keys (camelCased). The non-public part of runtimeConfig is server-only — the value never reaches the browser bundle.
Now expose the SnapSharp client via a server util at server/utils/snapsharp.ts:
import { SnapSharp } from '@snapsharp/sdk';
let _snap: SnapSharp | null = null;
export function useSnapSharp() {
if (!_snap) {
const config = useRuntimeConfig();
if (!config.snapsharpApiKey) {
throw createError({ statusCode: 500, message: 'SNAPSHARP_API_KEY missing' });
}
_snap = new SnapSharp(config.snapsharpApiKey);
}
return _snap;
}useRuntimeConfig and createError are auto-imported by Nitro inside server/.
Step 2: a basic screenshot endpoint
Server routes live in server/api/. Create server/api/screenshot.get.ts:
export default defineEventHandler(async (event) => {
const { url } = getQuery(event);
if (!url || typeof url !== 'string') {
throw createError({ statusCode: 400, message: 'url required' });
}
const snap = useSnapSharp();
try {
const image = await snap.screenshot(url, {
width: 1280,
height: 720,
format: 'png',
blockAds: true,
});
setHeader(event, 'Content-Type', 'image/png');
setHeader(
event,
'Cache-Control',
'public, max-age=86400, stale-while-revalidate=604800'
);
return image;
} catch (err) {
console.error('Screenshot failed:', err);
throw createError({ statusCode: 502, message: 'capture failed' });
}
});Visit /api/screenshot?url=https://github.com and you'll get the image. The naming convention screenshot.get.ts means "GET only" — Nitro routes the HTTP method automatically.
Step 3: dynamic OG images for content pages
Nuxt doesn't have an opengraph-image convention, so we use the same server route pattern. Create server/routes/blog/[slug]/og.png.get.ts:
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug');
if (!slug) {
throw createError({ statusCode: 400, message: 'slug required' });
}
// Replace this with your real post fetcher
const post = await getPost(slug);
if (!post) throw createError({ statusCode: 404 });
const snap = useSnapSharp();
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,
});
setHeader(event, 'Content-Type', 'image/png');
setHeader(event, 'Cache-Control', 'public, max-age=86400');
return image;
});Note server/routes/ (not server/api/) — routes here don't have an /api prefix, so the URL is just /blog/:slug/og.png.
Reference the OG image from your post page using useHead:
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const slug = route.params.slug as string;
const { data: post } = await useFetch(`/api/posts/${slug}`);
const config = useRuntimeConfig();
const ogImageUrl = `${config.public.siteUrl}/blog/${slug}/og.png`;
useHead({
title: () => post.value?.title ?? '',
meta: [
{ property: 'og:title', content: () => post.value?.title ?? '' },
{ property: 'og:description', content: () => post.value?.excerpt ?? '' },
{ property: 'og:image', content: ogImageUrl },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:image', content: ogImageUrl },
],
});
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<div v-html="post.html" />
</article>
</template>When a social platform scrapes the post URL, it reads og:image, fetches /blog/:slug/og.png, and your Nitro route serves a SnapSharp-generated card. See OG image best practices for design tips.
Step 4: cached screenshot endpoint with Nitro
Nitro has a built-in cachedEventHandler that wraps a route in a cache layer. Use it to memoize screenshots by URL:
// server/api/screenshot.get.ts (refactored with cache)
export default defineCachedEventHandler(
async (event) => {
const { url } = getQuery(event);
if (!url || typeof url !== 'string') {
throw createError({ statusCode: 400, message: 'url required' });
}
const snap = useSnapSharp();
const image = await snap.screenshot(url, {
width: 1280,
height: 720,
format: 'png',
blockAds: true,
});
setHeader(event, 'Content-Type', 'image/png');
return image;
},
{
maxAge: 60 * 60, // 1 hour
name: 'screenshot',
getKey: (event) => {
const { url } = getQuery(event);
return `ss:${url}`;
},
}
);The cache backend defaults to memory in development and can be swapped for Redis, Cloudflare KV, or filesystem in production:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
storage: {
cache: { driver: 'redis', url: process.env.REDIS_URL },
},
},
});Now repeat requests for the same URL skip SnapSharp entirely — they hit your cache layer first. Costs flat-line as traffic grows.
Step 5: client-side composable for previews
If a Vue component needs to display a screenshot, use useFetch with proper key handling so the response is cached and reused across navigations:
<!-- components/SitePreview.vue -->
<script setup lang="ts">
const props = defineProps<{ url: string }>();
const screenshotUrl = computed(() =>
`/api/screenshot?url=${encodeURIComponent(props.url)}`
);
</script>
<template>
<div class="rounded-lg overflow-hidden border">
<img
:src="screenshotUrl"
:alt="`Screenshot of ${url}`"
width="1280"
height="720"
loading="lazy"
/>
</div>
</template>Don't use useFetch to fetch the binary into memory — let the browser pull it via the <img> tag so the platform CDN can cache it.
Step 6: production patterns
Validating user input
If your /api/screenshot accepts any URL from the client, sanity-check it:
function isPublicHttpUrl(input: string): boolean {
try {
const u = new URL(input);
if (!['http:', 'https:'].includes(u.protocol)) return false;
if (/^(localhost|127\.|10\.|192\.168\.|169\.254\.)/.test(u.hostname)) return false;
return true;
} catch {
return false;
}
}SnapSharp blocks internal IPs server-side too, but rejecting early saves an API call.
Rate limiting
For public endpoints, drop in a per-IP limiter using Nitro's useStorage:
async function rateLimit(ip: string, limit = 10, windowSec = 60): Promise<boolean> {
const storage = useStorage('cache');
const key = `rl:${ip}:${Math.floor(Date.now() / 1000 / windowSec)}`;
const count = (await storage.getItem<number>(key)) ?? 0;
if (count >= limit) return false;
await storage.setItem(key, count + 1, { ttl: windowSec });
return true;
}In the screenshot route:
const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
if (!(await rateLimit(ip))) {
throw createError({ statusCode: 429, message: 'rate limit exceeded' });
}Error handling
The SDK throws SnapSharpError with status codes. Map them to HTTP responses:
import { SnapSharpError } from '@snapsharp/sdk';
try {
await snap.screenshot(url);
} catch (err) {
if (err instanceof SnapSharpError) {
if (err.status === 429) throw createError({ statusCode: 429 });
if (err.status === 403) throw createError({ statusCode: 402, message: 'plan required' });
}
throw createError({ statusCode: 502, message: 'capture failed' });
}Step 7: deploying
Node server (nuxt build) — set NUXT_SNAPSHARP_API_KEY in your process manager. No special config.
Vercel preset — Nuxt detects Vercel automatically. Set the env var in the Vercel dashboard. To pin region, add to nuxt.config.ts:
export default defineNuxtConfig({
nitro: {
preset: 'vercel',
vercel: { regions: ['fra1'] },
},
});Cloudflare Pages — set the secret with wrangler or via the dashboard. Use nitro.preset: 'cloudflare-pages'. The SDK's fetch-based code path runs cleanly on Workers.
Netlify — set env var in dashboard, default preset auto-detects.
Common pitfalls
Pitfall 1: leaking the API key. Never put the key under runtimeConfig.public. The non-public branch of runtime config is server-only — the public branch is shipped to the browser.
Pitfall 2: forgetting setHeader('Content-Type'). Without it, the browser tries to render the binary as text and you see gibberish. Always set the content type explicitly.
Pitfall 3: defineCachedEventHandler in dev mode. The default memory driver means cache resets every reload. Use Redis or filesystem in production for cross-restart persistence.
Pitfall 4: useFetch on binary endpoints. useFetch parses responses as JSON or text by default. For binary, use <img src> directly or pass responseType: 'blob'.
Pitfall 5: cold starts on serverless presets. First request after idle takes 800-1500ms. Combine with cached event handler so most requests don't even invoke the function.
Final code
Three files:
server/utils/snapsharp.ts— typed client.server/api/screenshot.get.ts— capture endpoint with cache.server/routes/blog/[slug]/og.png.get.ts— per-post OG.
Plus runtime config and meta tags on the post page. ~70 lines total.
Conclusion
Nuxt 3's Nitro engine is built for this kind of integration. Server routes, runtime config, cached event handlers, and the storage abstraction make screenshot integration feel native. Pair it with any deploy preset and you have edge-cached OG images and on-demand captures with no Chromium binary in your bundle.
Next steps: explore custom OG templates, read the screenshot API reference, or compare the Astro content collection thumbnail tutorial for static-first patterns.
Related: OG image best practices · Pricing · Screenshot API comparison 2026