Next.js Guide
zumerlab/snapdom

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.

TL;DR

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