Capture HTML to PNG in Next.js
A copy-paste client component for the app router, the SSR pitfall to avoid, and a dynamic import pattern that keeps your bundle small.
SnapDOM needs a browser DOM. Use a client component for the ref and event handler, then import SnapDOM inside the handler. This keeps capture code out of server rendering.
1. Install
npm install @zumer/snapdom@latest
Run the capture in a browser event handler after the component has hydrated.
2. App Router — client component
Create a client component that owns the ref and capture logic. 'use client' defines a client boundary, but client components may still be prerendered. Importing SnapDOM inside the click handler keeps that import in the browser.
// app/capture/CaptureCard.tsx '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 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. The 'use client' boundary allows its event handler and ref; the dynamic import inside the handler runs when the user captures:
// 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)
The handler above already loads SnapDOM on demand. You can also load the entire capture component dynamically with SSR disabled. Put this dynamic() call inside a client component:
// app/capture/CaptureLazy.tsx 'use client'; import dynamic from 'next/dynamic'; export const CaptureCard = dynamic(() => import('./CaptureCard'), { ssr: false, loading: () => <p>Loading capture tool…</p>, });
Your server page then imports CaptureCard from this wrapper. In the pages router (pages/capture.tsx) there's no 'use client' directive and no restriction — ssr: false works directly in the page file.
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 { snapdom } = await import('@zumer/snapdom'); const blob = await snapdom.toBlob(cardRef.current, { scale: 2, format: '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 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
Import SnapDOM inside a browser event handler, as shown above, or load the capture component with dynamic(() => import('./CaptureCard'), { ssr: false }) from a client component. A 'use client' directive alone does not prevent prerendering.
The captured image is blank in production but works in dev
Check that the target is mounted, has a nonzero size and contains the expected data. Wait for layout-changing assets such as fonts. SnapDOM v3.x.x embeds used web fonts with embedFonts: 'auto' by default; a blank capture can have causes beyond font loading.
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 captures the rendered DOM, including supported pseudo-elements, custom fonts and open 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 its current browser layout.
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?
SnapDOM has an ES module entry and no native runtime dependencies. Keep its import and capture calls inside the browser boundary when using Turbopack or Webpack.
Try it in your app router project
Capture from a client event handler after the component has rendered.
Open the demo Installation