ScreenshotNeo

BlogEngineering

Using Paged.js with Next.js

Build paginated print layouts in Next.js with Paged.js, including client-only loading, CSS, PDF workflows, troubleshooting, and production guidance.

By the ScreenshotNeo team30 September 202610 min read

Using Paged.js with Next.js

Direct answer: Use Paged.js as a browser-side print-layout step inside a small Next.js Client Component. Render your document normally, wait until the content exists in the DOM, then pass that region to Paged.js. If the package touches window or document while it is imported, load the pagination component with next/dynamic and ssr: false. Keep data fetching and ordinary page structure in Server Components, and keep browser-only pagination code at the client boundary.

This approach follows the separate documented behavior of Paged.js and Next.js; there is no official, tested Paged.js and Next.js integration recipe or compatibility matrix. Treat the code below as an integration pattern, then verify the output in the browser and PDF workflow you deploy.

What Paged.js does in a Next.js application

Paged.js is an open-source browser library for displaying paginated content and producing print books with web technology. It applies print CSS, creates page boxes and inserts the generated pages into a target element. Its documented entry points include the npm Previewer, a browser polyfill, and a command-line route that uses a headless browser for PDF generation.

Next.js App Router pages are Server Components by default. Pagination is different from ordinary rendering because it needs a mounted DOM, browser APIs, fonts and often images. Put that work in a Client Component. Next.js documents next/dynamic with ssr: false for browser-only dependencies, and the option must be used from a Client Component.

  1. Fetch data and render the document shell in a Server Component.
  2. Pass serializable content or a client subtree to a pagination boundary.
  3. Mount a stable content element in the Client Component.
  4. Start one Paged.js preview after mount and after asynchronous content is ready.
  5. Expose print controls outside the generated page area.

Do not run pagination during server rendering. Do not let several pagination runs overlap: a second run can observe a half-generated DOM and produce duplicate or incomplete pages.

Keep data rendering on the server and run DOM pagination at the client boundary.
Keep data rendering on the server and run DOM pagination at the client boundary.

Install the packages

npm install pagedjs

Use the package version selected by your lockfile and verify it in your own browser build. The reviewed documentation does not establish a current Paged.js and Next.js version pairing.

Complete App Router example with the Previewer API

The npm Previewer gives your application explicit control over the source content, CSS files and destination element. It also returns a promise, which makes it useful when you need to enable a download button only after pagination completes.

Server page

\// app/invoice/page.tsx
import PaginatedInvoice from './PaginatedInvoice';

export default async function InvoicePage() {
  const invoice = {
    number: 'INV-1042',
    customer: 'Example customer',
    items: [
      { description: 'Design work', quantity: 2, price: 450 },
      { description: 'Implementation', quantity: 8, price: 125 }
    ]
  };

  return ;
}

Client pagination component

\// app/invoice/PaginatedInvoice.tsx
'use client';

import { useEffect, useRef, useState } from 'react';

export default function PaginatedInvoice({ invoice }) {
  const contentRef = useRef(null);
  const pagesRef = useRef(null);
  const [status, setStatus] = useState('Preparing document');

  useEffect(() => {
    let cancelled = false;
    let previewer;

    async function paginate() {
      if (!contentRef.current || !pagesRef.current) return;
      setStatus('Paginating');

      // Import inside the effect so browser-only code is not evaluated on the server.
      const { Previewer } = await import('pagedjs');
      if (cancelled) return;

      pagesRef.current.replaceChildren();
      previewer = new Previewer();
      await previewer.preview(
        contentRef.current,
        ['/print.css'],
        pagesRef.current
      );

      if (!cancelled) setStatus('Ready to print');
    }

    paginate().catch((error) => {
      console.error(error);
      if (!cancelled) setStatus('Pagination failed');
    });

    return () => {
      cancelled = true;
      if (pagesRef.current) pagesRef.current.replaceChildren();
    };
  }, []);

  return (
    <>
      <p aria-live='polite'>{status}</p>
      <div ref={contentRef} className='source-document'>
        <h1>Invoice {invoice.number}</h1>
        <p>Customer: {invoice.customer}</p>
        <table>
          <thead><tr><th>Description</th><th>Qty</th><th>Price</th></tr></thead>
          <tbody>
            {invoice.items.map((item) => (
              <tr key={item.description}>
                <td>{item.description}</td>
                <td>{item.quantity}</td>
                <td>${item.price}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <div ref={pagesRef} className='paged-output' />
    </>
  );
}
\/* public/print.css *\/
@page {
  size: A4;
  margin: 18mm 16mm 20mm;
}

@page {
  @bottom-right {
    content: counter(page);
  }
}

body {
  font-family: Arial, sans-serif;
  color: #111;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th, td {
  border-bottom: 1px solid #ccc;
  padding: 6pt;
  text-align: left;
}

tr {
  break-inside: avoid;
}

h1 {
  break-after: avoid;
}

Serve the stylesheet from public/print.css, which makes /print.css available to the Previewer. The exact page-size behavior depends on the browser. Paged.js documentation specifically discusses browser differences and limited support around @page { size }, so inspect the generated pages rather than assuming every browser will match.

Loading the whole pagination component with next/dynamic

Dynamic loading is useful when importing Paged.js itself or a wrapper causes browser-global errors at module evaluation time. The ssr: false option belongs in a Client Component.

\// app/invoice/InvoiceClient.tsx
'use client';

import dynamic from 'next/dynamic';

const BrowserPaginator = dynamic(() => import('./BrowserPaginator'), {
  ssr: false,
  loading: () => <p>Loading print layout…</p>
});

export default function InvoiceClient(props) {
  return <BrowserPaginator {...props} />;
}

Use either this boundary or an effect-time import. The effect-time import keeps the client bundle boundary smaller; dynamic loading can be clearer when a third-party wrapper performs work during import.

Using the browser polyfill instead

The polyfill is simpler when you want Paged.js to process a page rather than explicitly pass a source and target. Its documented configuration can disable automatic pagination and let your code call the preview later.

<script src='https://unpkg.com/pagedjs/dist/paged.polyfill.js'></script>
<script>
  window.PagedConfig = { auto: false };

  window.addEventListener('load', async () => {
    await document.fonts.ready;
    await window.PagedPolyfill.preview();
  });
</script>

In Next.js, place such browser-only behavior behind a Client Component and make sure the script is loaded only where it is needed. The Previewer is usually easier to coordinate with React state, route changes and cleanup.

Handling images, fonts and changing content

  • Images: wait for important images to complete before previewing. A layout pass that starts before intrinsic dimensions are known can move content across page boundaries.
  • Fonts: await document.fonts.ready when typography affects line wrapping. A late font swap can change page counts.
  • Async data: paginate after the data-driven subtree has rendered, not when the route first mounts.
  • Updates: re-run pagination when content meaningfully changes. Clear or replace the previous target first.
  • Long documents: avoid expensive state updates for every generated page; update status once per run.
async function waitForAssets(root) {
  await document.fonts.ready;
  const images = [...root.querySelectorAll('img')];
  await Promise.all(images.map((image) => {
    if (image.complete) return Promise.resolve();
    return new Promise((resolve) => {
      image.addEventListener('load', resolve, { once: true });
      image.addEventListener('error', resolve, { once: true });
    });
  }));
}

This waiting helper is integration advice for a DOM-based layout process, not a Next.js or Paged.js prescribed hook.

CSS options that matter for pagination

Requirement Useful CSS What to check
Keep a heading with its content break-after: avoid Confirm the heading does not strand at the page bottom.
Prevent table rows splitting break-inside: avoid Very large rows may still need a deliberate design.
Start a chapter on a new page break-before: page Check blank pages caused by preceding breaks.
Set paper dimensions @page { size; margin; } Browser support and print settings can differ.
Running headers and footers Paged.js running-element and margin-box features Verify support in the browser and output path you ship.

Generating PDFs: browser preview versus CLI

In-browser preview is appropriate when a user opens a document and prints it. It lets the user initiate pagination and inspect the result interactively. Paged.js also documents a CLI route using a headless browser. That route is better suited to server or build jobs where a controlled process generates PDFs without a user session.

A clean capture removes common overlays before producing the final image.
A clean capture removes common overlays before producing the final image.

For either route, define the browser, paper size, margins, fonts and asset URLs as part of the deployment contract. Compare output using the same browser version used in production. Do not assume that a successful Next.js HTML render guarantees identical PDF pagination.

Or skip the browser setup

If your requirement is a clean screenshot or PDF of a URL rather than custom CSS pagination inside your React tree, ScreenshotNeo provides a single HTTP request. It handles the browser capture step and can return PNG, JPEG, WebP or PDF. Cookie and consent banners are accepted and 60+ known consent platforms, newsletter popups and chat widgets are removed before the shot; each step can be turned off.

cURL

curl -G 'https://api.screenshotneo.com/v1/shot' \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    'https://api.screenshotneo.com/v1/shot',
    params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'},
    timeout=90,
)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

See the ScreenshotNeo API documentation for request options. Relevant controls include full-page capture with lazy images loaded, CSS-selector element capture, dark mode, device presets or custom viewports, retina scale, PDF paper size and page ranges, custom CSS and JavaScript, clicks, selector or network-idle waits, blocked ads and resource types, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, cache TTL, signed image links, asynchronous jobs with signed webhooks, bulk capture for 100 URLs per call, usage reporting and an OpenAPI specification.

Failed loads, bot checks, blank pages, timeouts and cache hits are not billed. Responses identify the result with X-Page-Verdict and X-Billed headers. An MCP server exposes take_screenshot, get_page_info and capture_pdf to Claude, Cursor and other MCP clients. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no cost.

Troubleshooting

window is not defined or document is not defined

Cause: Paged.js or a wrapper was imported during server rendering. Fix: add 'use client', move the import into a browser effect, or load the component with next/dynamic(..., { ssr: false }).

No pages appear

Cause: the destination ref is missing, the source is empty, or the preview failed before completion. Fix: check both refs, log the rejected promise, and call replaceChildren() before a retry.

Pages have the wrong breaks

Cause: fonts or images loaded after pagination, or a break rule conflicts with available space. Fix: wait for fonts and images, then re-run; inspect break-before, break-after and break-inside.

Styles are missing

Cause: the CSS path passed to Previewer is not publicly reachable or the stylesheet is scoped in a way the generated pages do not receive. Fix: serve print CSS from public, use an absolute or valid public path, and inspect the browser network panel.

Duplicate or stale output after navigation

Cause: an earlier asynchronous preview completed after a newer render. Fix: use a cancellation flag, clear the target before each run, and avoid starting a second run until the first promise settles.

PDF differs between machines

Cause: browser versions, print settings, fonts or support for @page features differ. Fix: pin the browser in automated jobs, package required fonts, set paper options explicitly and compare generated PDFs in the deployment environment.

Performance, reliability and cost

  • Performance: paginate only the document region, defer the work until the user requests print when appropriate, and avoid repeatedly rebuilding large React subtrees.
  • Reliability: make pagination idempotent, cancel stale runs, wait for assets and report failure to the user. Keep a non-paginated HTML fallback for accessibility and debugging.
  • Asset size: large images and web fonts increase layout time and memory use. Resize images before embedding them and avoid loading unrelated application resources.
  • Server cost: browser-side pagination consumes the visitor’s browser. A headless CLI or hosted capture service moves that work to an automated environment and should be evaluated against your job volume and repeatability requirements.
  • ScreenshotNeo billing: only clean shots are billed; bot checks, blank pages, failed loads, timeouts and cache hits cost nothing. Plans are Free 1,000/month, Starter $5/3,000, Growth $15/15,000, Pro $39/60,000, Scale $99/250,000 and Business $249/1,000,000; yearly billing provides two months free.

Checklist before shipping

  • Pagination starts only in a Client Component.
  • Browser-only imports are dynamically loaded or deferred until mount.
  • Fonts and important images are ready before preview.
  • Previous generated pages are cleared before a rerun.
  • Print CSS defines page size, margins and break behavior.
  • Output is checked in the browser and PDF path you support.
  • A failure state and ordinary HTML fallback remain usable.
  • Automated jobs use a controlled browser and stable assets.

FAQ

Can Paged.js run in a Next.js Server Component?

The pagination step cannot, because it needs a browser DOM. Keep the surrounding route and data work on the server, then pass the rendered content to a Client Component.

Should I use Previewer or the polyfill?

Use Previewer when React controls timing, content and the destination element. Use the polyfill when you want a simpler page-level setup with automatic or manually triggered preview.

Does Paged.js guarantee identical PDFs in every browser?

No. Browser print behavior, page sizing and CSS support vary. Validate the exact browser and PDF workflow that matters to your application.

When is ScreenshotNeo a better fit?

Use it when you need a URL capture or PDF without maintaining browser orchestration, especially when consent banners, popups, failed pages, asynchronous jobs or AI-agent access are part of the workflow.