If you've ever tried to add screenshots or OG images to a Next.js app, you've probably hit one of three walls: bloated node_modules from Puppeteer, cold-start timeouts on Vercel, or @vercel/og's missing CSS features. None of those problems are interesting. They're plumbing. This tutorial replaces all of them with one HTTP call.
By the end you'll have:
- A
/api/screenshotroute that captures any URL. - Per-page OG images generated at build time.
- A Server Component that embeds a live screenshot.
- Production-grade caching and error handling on Vercel.
We'll use Next.js 15 (App Router), React 19, and the SnapSharp API. No Playwright, no Puppeteer, no Chromium binaries.
Prerequisites
- Node.js 20+ and pnpm (or npm).
- A Next.js 15 project (
pnpm create next-app@latest). - A free SnapSharp API key from snapsharp.dev/sign-up.
- Basic familiarity with App Router and Server Components.
Step 1: install the SDK and configure env
The official Node SDK is ~50 KB and has zero runtime dependencies. It works in Node, Bun, and Edge Runtime.
pnpm add @snapsharp/sdkAdd your API key to .env.local. Never commit this file.
SNAPSHARP_API_KEY=sk_live_YOUR_API_KEYCreate lib/snapsharp.ts to centralize the client:
import { SnapSharp } from '@snapsharp/sdk';
if (!process.env.SNAPSHARP_API_KEY) {
throw new Error('SNAPSHARP_API_KEY is required');
}
export const snap = new SnapSharp(process.env.SNAPSHARP_API_KEY);The SNAPSHARP_API_KEY is server-only — it must never be exposed via NEXT_PUBLIC_*. Anyone who has it can spend your quota. We'll proxy requests through Next.js routes to keep it private.
Step 2: a basic screenshot route handler
Create app/api/screenshot/route.ts. App Router route handlers are the equivalent of Pages Router's pages/api/* — they run on the server, can return any Response, and inherit Next.js's caching primitives.
import { NextRequest, NextResponse } from 'next/server';
import { snap } from '@/lib/snapsharp';
export const runtime = 'nodejs';
export const revalidate = 3600; // cache 1 hour
export async function GET(req: NextRequest) {
const url = req.nextUrl.searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'url required' }, { status: 400 });
}
try {
const image = await snap.screenshot(url, {
width: 1280,
height: 720,
format: 'png',
blockAds: true,
});
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
});
} catch (err) {
console.error('Screenshot failed:', err);
return NextResponse.json({ error: 'capture failed' }, { status: 502 });
}
}Visit /api/screenshot?url=https://github.com and you'll see a live PNG. Two things to notice: the Cache-Control header tells Vercel's CDN to serve the image for a day before revalidating, and stale-while-revalidate lets stale images serve while a new one is fetched in the background. This makes the second hit on every URL nearly free.
Step 3: per-page OG images at build time
OG images that change per page (blog post, user profile, product) are where Next.js gets interesting. With the App Router, you can colocate an opengraph-image route next to any page. Next.js wires up the <meta property="og:image"> tag automatically.
Create app/blog/[slug]/opengraph-image.tsx:
import { snap } from '@/lib/snapsharp';
import { getPost } from '@/lib/posts';
export const runtime = 'nodejs';
export const contentType = 'image/png';
export const size = { width: 1200, height: 630 };
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
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,
});
return new Response(image, {
headers: { 'Content-Type': 'image/png' },
});
}That's the entire integration. Next.js will:
- Call this function during the build (or on-demand if
dynamic = 'force-dynamic'). - Inject
<meta property="og:image" content="..." />into the page's HTML. - Cache the image on Vercel's edge network.
Test by sharing the URL on Twitter, LinkedIn, or in Slack. Every page now has a unique, branded preview card. See OG image best practices for design tips.
Step 4: embedding a live screenshot in a Server Component
Sometimes you want to show a screenshot inline — for example, a "site preview" tile in a directory or dashboard. With Server Components, you can call SnapSharp directly from JSX without a roundtrip through a client API.
// app/sites/[domain]/page.tsx
import Image from 'next/image';
interface Props {
params: { domain: string };
}
export default async function SitePage({ params }: Props) {
const url = `https://${params.domain}`;
const screenshotSrc = `/api/screenshot?url=${encodeURIComponent(url)}`;
return (
<main className="mx-auto max-w-4xl p-8">
<h1 className="text-3xl font-bold">{params.domain}</h1>
<div className="mt-6 overflow-hidden rounded-xl border">
<Image
src={screenshotSrc}
alt={`Screenshot of ${params.domain}`}
width={1280}
height={720}
className="w-full"
/>
</div>
</main>
);
}We delegate to the route handler we built in Step 2 instead of calling SnapSharp directly here. Why? Because Next.js's <Image> component needs a URL it can re-fetch, optimize, and cache. Returning binary data from a Server Component bypasses all of that.
If you'd rather embed the binary directly (no <Image>, no CDN optimization), use dangerouslySetInnerHTML with a base64 data URL — but that's almost always the wrong call. The CDN approach scales better.
Step 5: production patterns
Signed URLs for client-side embedding
If you need to expose a screenshot URL to the browser (for example, a "share this preview" button), don't expose your API key. Instead, generate signed URLs server-side that expire after a short window.
// app/api/preview-url/route.ts
import crypto from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const url = req.nextUrl.searchParams.get('url');
if (!url) return NextResponse.json({ error: 'url required' }, { status: 400 });
const expires = Math.floor(Date.now() / 1000) + 300; // 5 min
const payload = `${url}|${expires}`;
const sig = crypto
.createHmac('sha256', process.env.SIGNING_SECRET!)
.update(payload)
.digest('hex');
const proxyUrl = `/api/screenshot/signed?url=${encodeURIComponent(url)}&exp=${expires}&sig=${sig}`;
return NextResponse.json({ url: proxyUrl });
}The signed proxy verifies the signature before calling SnapSharp. Clients can't forge URLs, can't reuse them past expiry, and never see your API key.
Error boundaries
A failed screenshot shouldn't crash the page. Wrap calls in try/catch and fall back to a placeholder.
async function safeScreenshot(url: string) {
try {
return await snap.screenshot(url, { width: 1280, height: 720 });
} catch (err) {
console.error('Snap failed for', url, err);
return null;
}
}In a Server Component, returning null lets you render a fallback UI inline:
{image ? <img src={imageSrc} /> : <div className="bg-gray-100 h-72 grid place-items-center">Preview unavailable</div>}Rate limit awareness
The free tier is 100 requests/month at 5 req/min. The 429 response includes a Retry-After header. The SDK throws a typed error you can catch:
import { SnapSharpError } from '@snapsharp/sdk';
try {
await snap.screenshot(url);
} catch (err) {
if (err instanceof SnapSharpError && err.status === 429) {
// back off; cache a placeholder
}
}For production sites with traffic, upgrade to Starter ($19/mo, 5,000 requests). See pricing.
Step 6: deploying to Vercel
Vercel and the App Router play well with this setup. Two things to configure:
- Environment variable: add
SNAPSHARP_API_KEYin your Vercel project settings → Environment Variables → Production + Preview. - Function region: the SnapSharp API is hosted in EU. To minimize round-trip time, set your Vercel function region to
fra1orcdg1innext.config.js:
module.exports = {
experimental: {
serverActions: { allowedOrigins: ['*'] },
},
};Or per-route via the runtime and preferredRegion exports:
export const runtime = 'nodejs';
export const preferredRegion = 'fra1';Edge Runtime works too, but watch for binary buffer size — SnapSharp images can hit several MB at full retina. Node runtime handles them better.
Common pitfalls
Pitfall 1: cold starts on serverless. First request after idle takes 800–1500ms because Node has to boot. Mitigate with revalidate set to a non-zero value so most requests hit cache, not the function.
Pitfall 2: caching the wrong URL. Vercel caches by full URL including query string. If you accidentally pass a timestamp or random ID, every request bypasses cache. Use stable parameters and let SnapSharp's CDN handle freshness.
Pitfall 3: leaking the API key. Never put SNAPSHARP_API_KEY in a NEXT_PUBLIC_* variable, never log the full key, and never include it in client-side fetch calls. Always proxy through your route handlers.
Pitfall 4: screenshotting auth-walled pages. SnapSharp can't see pages behind your login wall by default. Use cookies parameter to pass session cookies, or render a public preview version of the page.
Pitfall 5: ignoring wait_for. Some pages have async content that loads after networkidle. Pass wait_for: '.main-content' to wait for a specific element before capturing — see the screenshot docs.
Final code
The full example lives in your repo as:
lib/snapsharp.ts— typed client.app/api/screenshot/route.ts— proxied capture endpoint.app/blog/[slug]/opengraph-image.tsx— per-post OG.app/sites/[domain]/page.tsx— Server Component preview.
Three files, ~80 lines of code, full test coverage from Next.js's existing infrastructure.
Conclusion
Next.js 15 App Router was built for this kind of integration. Server Components let you call SnapSharp directly. Route handlers proxy and cache cleanly. The opengraph-image convention wires up social previews automatically. Pair it with SnapSharp's CDN and you've got production-grade screenshots and OG images without managing browsers, fonts, or layout engines.
Next steps: explore custom OG templates, read the screenshot API reference, or compare SnapSharp to ScreenshotOne and Urlbox.
Related: OG image best practices · Pricing · Node.js screenshot methods