SnapDOM capture code must execute inside a real browser page. For an in-app export, use a Client Component and call it after hydration. For server-generated output, let a Node server action or job control Playwright or Puppeteer, inject SnapDOM into that page, and return the exported data.
1. Install
npm install @zumer/snapdom
The client pattern works in both the App Router and Pages Router. A server-controlled capture also needs Playwright or Puppeteer and browser infrastructure available to the deployment.
2. App Router: client component
Create a Client Component that owns the ref and the capture handler. The 'use client' directive is required because this component uses useRef and an onClick handler. Importing the module is not the failure boundary; invoking a capture without a browser DOM is.
// 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> ); }
Try it live
This is what CaptureCard does after hydration, running on this page. The button fires snapdom.toPng(cardRef.current) and the captured PNG appears below, all client-side, exactly like it will in your Next.js app.
Hello SnapDOM
Captured from a Next.js client component.
3. Use it from a server page
Server Components can render this Client Component as a child. Next.js may prerender its initial HTML, but the click handler and SnapDOM capture run only after hydration 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. Lazy-load SnapDOM on the first capture
If capture is an occasional action, dynamically import the library inside the Client Component handler. This creates a separate client chunk without wrapping the whole component in next/dynamic or disabling its prerendered HTML:
'use client' import { useRef } from 'react' export default function CaptureCard() { const cardRef = useRef<HTMLDivElement>(null) async function handleCapture() { if (!cardRef.current) return const { snapdom } = await import('@zumer/snapdom') const image = await snapdom.toPng(cardRef.current) document.body.appendChild(image) } return ( <> <article ref={cardRef}>Rendered report</article> <button onClick={handleCapture}>Capture</button> </> ) }
Use next/dynamic({ ssr: false }) only when the entire child component must skip prerendering. Next.js requires that option to live in a Client Component.
5. Run a capture from a Server Action
A Server Action cannot call SnapDOM against raw HTML in its own Node process. It can be the coordinator: launch a browser, load the real page, inject the installed IIFE bundle, run SnapDOM in the page context and return a serializable result.
npm install playwright npx playwright install chromium
// app/actions/capture-report.ts 'use server' import { createRequire } from 'node:module' import { chromium } from 'playwright' const require = createRequire(import.meta.url) const snapdomBundlePath = require.resolve('@zumer/snapdom') export async function captureReport() { // Authenticate and authorize the caller here. // Apply app-specific rate and concurrency limits before launch. const browser = await chromium.launch() try { const context = await browser.newContext({ bypassCSP: true }) const page = await context.newPage() await page.goto('https://app.example.com/report', { waitUntil: 'domcontentloaded' }) await page.locator('#report[data-export-ready="true"]').waitFor() await page.evaluate(async () => { await document.fonts.ready }) await page.addScriptTag({ path: snapdomBundlePath }) return await page.locator('#report').evaluate(async (element) => { const pageWindow = window as typeof window & { snapdom: typeof import('@zumer/snapdom').snapdom } const image = await pageWindow.snapdom.toPng(element, { dpr: 1 }) return image.src }) } finally { await browser.close() } }
Keep this path on the Node runtime. Set export const runtime = 'nodejs' and, when your host supports it, an adequate maxDuration on the page, layout or route that owns the action. The deployment must install Chromium during its build or connect to a browser service.
A fresh browser context has no user session. Authenticate explicitly with narrowly scoped credentials or a one-time signed URL. Validate or allowlist every target, never forward caller cookies to an untrusted URL, and enforce authorization plus rate and concurrency limits before launching the browser. For large images, store the bytes and return an asset URL instead of sending a long data URL through the action.
The DOM capture boundaries post has the complete installed-bundle recipe, readiness checks and Puppeteer equivalent. For a single direct bitmap with no SVG, plugins or reusable exports, page.screenshot() is usually simpler.
6. 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, type: 'png' }); const form = new FormData(); form.append('file', blob, 'share.png'); await fetch('/api/upload', { method: 'POST', body: form }); }
For server-rendered OG images that do not need a real browser, Vercel's @vercel/og is a different tool. If the card must match a real page, the Server Action pattern above can capture it in a controlled browser.
Common gotchas
Why do I get "window is not defined" in Next.js?
A capture was invoked while rendering without a browser DOM. For an in-app export, keep the ref and capture call in a Client Component and trigger it after hydration. For backend output, run the call inside a page controlled by Playwright or Puppeteer.
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 without a full browser CSS engine. SnapDOM executes inside a real browser page—either the user's page or one controlled from Node—and can capture the rendered UI including pseudo-elements, custom fonts and Web Components.
Use @vercel/og when the card is fully described in supported JSX and a browser is unnecessary. Use SnapDOM when the image must come from the actual rendered page.
Can I run SnapDOM in a server action?
Not directly in the action's Node process, because it has no DOM. A Node-runtime action can launch or connect to Playwright or Puppeteer, inject SnapDOM into the loaded page, run it inside page.evaluate() or locator.evaluate(), and return a data URL or stored asset URL.
Does SnapDOM work with the Pages Router too?
Yes. Call SnapDOM from an event handler or effect after the element exists. A backend job using the Pages Router can also control a browser and inject SnapDOM into the rendered page.
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.
Choose the browser that owns the capture
Use the hydrated page for user-triggered exports, or inject SnapDOM into a browser controlled from your Node backend.
Open the demo Install from npm