SnapDOM is a browser-only library — it must run on the client. Mark your component with 'use client' (app router) or import SnapDOM dynamically with ssr: false. Then it's a regular React capture: useRef + snapdom.toPng(ref.current).
1. Install
npm install @zumer/snapdom
Works with Next.js 13, 14 and 15. No special config required.
2. App Router — client component
Create a client component that owns the ref and the capture logic. The 'use client' directive on top is critical: without it Next.js will try to evaluate SnapDOM during the server render and crash.
// app/capture/CaptureCard.tsx 'use client'; import { useRef } from 'react'; import { snapdom } from '@zumer/snapdom'; export default function CaptureCard() { const cardRef = useRef<HTMLDivElement>(null); async function handleCapture() { if (!cardRef.current) return; const img = await snapdom.toPng(cardRef.current, { scale: 2 }); document.body.appendChild(img); } return ( <div> <div ref={cardRef} className="card"> <h3>Hello SnapDOM</h3> <p>Captured from a Next.js client component.</p> </div> <button onClick={handleCapture}>Capture</button> </div> ); }
3. Use it from a server page
Server components can render the client component as a child. The boundary is the 'use client' directive — anything below it runs in the browser:
// app/capture/page.tsx import CaptureCard from './CaptureCard'; export default function Page() { return ( <main> <h1>SnapDOM in Next.js</h1> <CaptureCard /> </main> ); }
4. Alternative — dynamic import (lazy load)
If you want to keep SnapDOM out of the initial bundle, import the client component dynamically with SSR disabled:
// app/capture/page.tsx import dynamic from 'next/dynamic'; const CaptureCard = dynamic(() => import('./CaptureCard'), { ssr: false, loading: () => <p>Loading capture tool…</p>, }); export default function Page() { return <CaptureCard />; }
This pattern is also useful for the pages router (pages/capture.tsx) where there's no 'use client' directive — ssr: false is your only opt-out from server rendering.
5. Bonus — dynamic OG / share images
SnapDOM is great for generating share cards on the fly when the user clicks "share". Capture the rendered card to a Blob, upload it to your CDN, and use that URL as the og:image for the share link:
async function share() { if (!cardRef.current) return; const blob = await snapdom.toBlob(cardRef.current, { scale: 2 }); const form = new FormData(); form.append('file', blob, 'share.png'); await fetch('/api/upload', { method: 'POST', body: form }); }
For server-rendered OG images that don't need a real browser, Vercel's @vercel/og is a different tool — see the FAQ below.
Common gotchas
"window is not defined" / "document is not defined" errors
You imported SnapDOM at the top of a server component. Either add 'use client' at the top of the file, or use dynamic(() => import('./CaptureCard'), { ssr: false }).
The captured image is blank in production but works in dev
This usually means a font hasn't finished loading by the time you triggered the capture. Pass { embedFonts: true } and consider calling the optional preCache helper once on mount:
import { preCache } from '@zumer/snapdom/preCache';
Should I use SnapDOM or @vercel/og for OG images?
Different tools. @vercel/og renders Satori-compatible JSX on the server (no real browser, no full CSS). SnapDOM runs client-side in a real browser and supports the entire CSS engine including pseudo-elements, custom fonts, animations and Web Components.
Use @vercel/og when the card is fully described in JSX and you don't need real browser fidelity. Use SnapDOM when you want to capture a piece of your live UI with pixel accuracy.
Can I run SnapDOM in a server action?
No. Server actions run in Node.js without a real DOM. Use Puppeteer or Playwright if you need server-side screenshots, and SnapDOM client-side.
Cross-origin images break the capture
Pass a CORS proxy via { useProxy: 'https://your-proxy.example.com/' }. SnapDOM will fetch external images through the proxy and inline them as data URLs.
Does it work with Turbopack?
Yes. SnapDOM is a plain ES module with no native dependencies, so Turbopack and Webpack handle it identically.
Try it in your app router project
Drop SnapDOM into a client component and capture your first element in under a minute.
Open the demo Install from npm