This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /blog/how-to-build-a-nextjs-pdf-viewer.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. Next.js PDF viewer with React-PDF and Nutrient SDK

Table of contents

    Next.js PDF viewer with React-PDF and Nutrient SDK
    See a full-featured PDF viewer in React

    Nutrient Web SDK renders, annotates, and fills forms client side. Try the live demo - no signup required.

    TL;DR

    To display a PDF in Next.js, load the viewer in a Client Component and keep browser-only imports out of server rendering. This tutorial uses React-PDF for page rendering and navigation. Then it shows Nutrient Web SDK for a prebuilt viewer with annotation and editing tools. Both examples use local PDF files and self-hosted runtime assets.

    A Next.js PDF viewer displays documents inside your application, such as an invoice in a customer portal or a report in a dashboard. The browser still retrieves the PDF bytes; embedding a viewer doesn’t prevent downloads or replace access control.

    You’ll build a viewer that displays one page at a time, with previous and next buttons, a page counter, selectable text, and loading and error messages. The examples use the App Router and JavaScript throughout.

    Prerequisites

    Use Node.js 24.15 or a later compatible 24.x release, npm, and a modern browser. This tutorial targets Next.js 16.3.5, React-PDF 11.0.0, and Nutrient Web SDK 1.21.0. The versions are pinned so the code and runtime assets stay aligned.

    React-PDF(opens in a new tab) displays existing PDFs. It’s a different package from @react-pdf/renderer, which creates PDFs from React components.

    Create a Next.js application

    Run the following commands to create a JavaScript project with the App Router and a src directory:

    Terminal window
    npx create-next-app@16.3.5 next-viewer --js --eslint --no-tailwind --src-dir --app --use-npm --import-alias "@/*" --yes
    cd next-viewer
    npm install --save-exact react-pdf@11.0.0

    Place a PDF named example.pdf in the root-level public directory. You can use our sample PDF. Keep public alongside src, rather than inside it.

    The files you’ll add or replace are:

    next-viewer/
    ├── public/
    │ └── example.pdf
    ├── scripts/
    │ └── copy-pdf-assets.cjs
    └── src/
    ├── app/
    │ ├── layout.js
    │ └── page.js
    └── components/
    ├── PDFViewer.js
    └── PDFViewerClient.js

    Configure the PDF.js worker

    React-PDF uses PDF.js to parse and render documents. Its worker must match the PDF.js version installed with React-PDF.

    Create scripts/copy-pdf-assets.cjs. This script resolves PDF.js from React-PDF’s dependencies and copies its worker, character maps, fonts, and WebAssembly files into public/pdfjs:

    const fs = require('node:fs');
    const path = require('node:path');
    const reactPdfDirectory = path.dirname(require.resolve('react-pdf'));
    const pdfjsDirectory = path.dirname(
    require.resolve('pdfjs-dist/package.json', {
    paths: [reactPdfDirectory],
    }),
    );
    fs.mkdirSync('public/pdfjs', { recursive: true });
    fs.copyFileSync(
    path.join(pdfjsDirectory, 'build/pdf.worker.min.mjs'),
    'public/pdfjs/pdf.worker.min.mjs',
    );
    for (const directory of ['cmaps', 'standard_fonts', 'wasm']) {
    fs.cpSync(
    path.join(pdfjsDirectory, directory),
    path.join('public/pdfjs', directory),
    { recursive: true },
    );
    }

    Run it before starting the app:

    Terminal window
    node scripts/copy-pdf-assets.cjs

    Rerun the script after changing React-PDF versions. Add this step to your deployment build if generated assets aren’t committed. This setup serves assets as static files and doesn’t require a custom webpack or Turbopack configuration.

    Build the React-PDF viewer

    Create src/components/PDFViewerClient.js with the following code:

    'use client';
    import { useEffect, useRef, useState } from 'react';
    import { Document, Page, pdfjs } from 'react-pdf';
    import 'react-pdf/dist/Page/TextLayer.css';
    import 'react-pdf/dist/Page/AnnotationLayer.css';
    pdfjs.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs';
    const options = {
    cMapUrl: '/pdfjs/cmaps/',
    standardFontDataUrl: '/pdfjs/standard_fonts/',
    wasmUrl: '/pdfjs/wasm/',
    };
    export default function PDFViewerClient() {
    const containerRef = useRef(null);
    const [width, setWidth] = useState(0);
    const [numPages, setNumPages] = useState(0);
    const [pageNumber, setPageNumber] = useState(1);
    const [failed, setFailed] = useState(false);
    useEffect(() => {
    const observer = new ResizeObserver(([entry]) => {
    setWidth(Math.floor(entry.contentRect.width));
    });
    observer.observe(containerRef.current);
    return () => observer.disconnect();
    }, []);
    return (
    <section aria-label="PDF viewer">
    <nav aria-label="PDF page navigation">
    <button
    type="button"
    disabled={!numPages || pageNumber <= 1}
    onClick={() => setPageNumber((page) => Math.max(1, page - 1))}
    >
    Previous
    </button>
    <button
    type="button"
    disabled={!numPages || pageNumber >= numPages}
    onClick={() =>
    setPageNumber((page) => Math.min(numPages, page + 1))
    }
    >
    Next
    </button>
    <p aria-live="polite">
    {numPages
    ? 'Page ' + pageNumber + ' of ' + numPages
    : failed ? 'PDF unavailable.' : 'Loading PDF…'}
    </p>
    </nav>
    <div ref={containerRef} style={{ width: '100%', maxWidth: 800 }}>
    <Document
    file="/example.pdf"
    options={options}
    suspense={false}
    onLoadSuccess={({ numPages }) => {
    setNumPages(numPages);
    setPageNumber(1);
    setFailed(false);
    }}
    onLoadError={() => {
    setNumPages(0);
    setFailed(true);
    }}
    loading={<p>Loading document…</p>}
    error={<p role="alert">Could not load the PDF. Check its URL and permissions.</p>}
    onItemClick={({ pageNumber }) => {
    if (pageNumber) setPageNumber(pageNumber);
    }}
    >
    {width > 0 && (
    <Page
    pageNumber={pageNumber}
    width={width}
    renderTextLayer
    renderAnnotationLayer
    loading={<p>Loading page…</p>}
    error={<p role="alert">Could not render this page.</p>}
    />
    )}
    </Document>
    </div>
    </section>
    );
    }

    The ResizeObserver measures the viewer’s container so PDF pages fit narrower screens. Both buttons stay disabled until the document loads. Previous is disabled on the first page, and Next is disabled on the last.

    React-PDF 11 uses React Suspense by default. Setting suspense={false} enables the loading and error props used here. Keep the worker configuration in the same module as Document and Page, as described in the React-PDF documentation(opens in a new tab).

    Keep browser-only imports out of server rendering

    A 'use client' directive creates a Client Component boundary, but Next.js can still prerender that component on the server. Browser-only dependencies need an additional boundary.

    Create src/components/PDFViewer.js:

    'use client';
    import dynamic from 'next/dynamic';
    const PDFViewerClient = dynamic(() => import('./PDFViewerClient'), {
    ssr: false,
    loading: () => <p>Loading viewer…</p>,
    });
    export default function PDFViewer() {
    return <PDFViewerClient />;
    }

    Here, ssr: false disables server rendering for the imported viewer. Next.js requires this option to be declared in a Client Component. Other uses of dynamic() don’t automatically disable server rendering; see the Next.js lazy loading guide(opens in a new tab).

    Replace src/app/page.js with:

    import PDFViewer from '@/components/PDFViewer';
    export default function Home() {
    return (
    <main style={{ padding: 16 }}>
    <h1>PDF viewer</h1>
    <PDFViewer />
    </main>
    );
    }

    The page itself can remain a Server Component. To keep the example independent of the generated starter page’s fonts and styles, replace src/app/layout.js with:

    export default function RootLayout({ children }) {
    return (
    <html lang="en">
    <body style={{ margin: 0, fontFamily: 'sans-serif' }}>{children}</body>
    </html>
    );
    }

    Run and test the Next.js PDF viewer

    Start the development server and open http://localhost:3000:

    Terminal window
    npm run dev

    Test with a multipage PDF. Check that Previous is disabled on the first page and Next on the last page. Resize the browser, select some text, and follow an internal PDF link if your document contains one.

    Temporarily rename public/example.pdf and reload to check the error message. Restore the file before building. Then stop the development server and test the production build:

    Terminal window
    npm run build
    npm start
    0:00
    0:00

    The video illustrates page navigation; the code above is the current implementation.

    Add Nutrient Web SDK to Next.js

    Nutrient Web SDK provides a prebuilt UI for viewing, annotating, editing, and filling PDF forms. Choose it when those tools are part of your application’s requirements. Available capabilities depend on your license.

    You can add it to the same Next.js project. Install the pinned package:

    Terminal window
    npm install --save-exact @nutrient-sdk/viewer@1.21.0

    Download the matching runtime assets archive(opens in a new tab) and extract its contents directly into public. The result must include public/nutrient-viewer-lib/. The npm package and archive must use the same version.

    The archive contains runtime dependencies; the npm package supplies the SDK itself. Refer to the self-hosting guide for deployment details.

    Create the Nutrient component

    Create src/components/NutrientPDFViewer.js. The SDK import runs inside an effect, after the component mounts in the browser:

    'use client';
    import { useEffect, useRef, useState } from 'react';
    export default function NutrientPDFViewer() {
    const containerRef = useRef(null);
    const [error, setError] = useState('');
    useEffect(() => {
    const container = containerRef.current;
    const controller = new AbortController();
    let NutrientViewer;
    async function openPDF() {
    NutrientViewer = (await import('@nutrient-sdk/viewer')).default;
    if (controller.signal.aborted) return;
    const response = await fetch('/example.pdf', {
    signal: controller.signal,
    });
    if (!response.ok) {
    throw new Error('PDF download failed: ' + response.status);
    }
    const document = await response.arrayBuffer();
    if (controller.signal.aborted) return;
    await NutrientViewer.load({
    container,
    document,
    baseUrl: window.location.origin + '/',
    signal: controller.signal,
    // Add your production license key here when deploying.
    });
    }
    openPDF().catch((loadError) => {
    if (!controller.signal.aborted) {
    setError(loadError.message || 'Could not open the PDF.');
    }
    });
    return () => {
    controller.abort();
    NutrientViewer?.unload(container);
    };
    }, []);
    return (
    <section aria-label="Nutrient PDF viewer">
    {error && <p role="alert">{error}</p>}
    <div ref={containerRef} style={{ width: '100%', height: '85vh' }} />
    </section>
    );
    }

    The effect returns its cleanup function synchronously. It cancels pending work and unloads the viewer when React unmounts the component, including during React Strict Mode’s development-only checks.

    The download status check reports a missing file before passing its contents to the SDK. This example fetches the whole PDF into memory; for large documents, evaluate the SDK’s URL-based loading with your server’s range-request support.

    Replace src/app/page.js to display the Nutrient component:

    import NutrientPDFViewer from '@/components/NutrientPDFViewer';
    export default function Home() {
    return (
    <main>
    <h1>PDF viewer</h1>
    <NutrientPDFViewer />
    </main>
    );
    }

    Run the app and check loading, route changes, and the production build again. For other installation options and TypeScript examples, use the Nutrient Next.js integration guide. Get a trial license before evaluating licensed features.

    Extend the viewer

    Use the following guides for document workflows:

    Troubleshooting

    SymptomWhat to check
    DOMMatrix is not defined or window is not definedKeep React-PDF inside the dynamically imported component with ssr: false. Import Nutrient inside the effect.
    Worker or API version mismatchRerun the asset-copy script and clear stale deployed worker files after upgrading React-PDF.
    Missing fonts or images in a PDFCopy the cmaps, standard_fonts, and wasm directories, and check their requests in browser developer tools.
    A local PDF returns 404Put it in the root-level public directory and request /example.pdf, not /public/example.pdf.
    A remote PDF fails to loadCheck authentication and cross-origin resource sharing (CORS) on the document server.
    The Nutrient container is blankCheck its height, the SDK import, the PDF request, and the path to nutrient-viewer-lib.
    Deployment under a subpath failsPrefix document and asset URLs with your deployment path; these examples assume the site root.

    Choose the viewer for your workflow

    React-PDF provides rendering components you can combine with your own controls. Its annotation layer displays existing links, and its renderForms option can render form widgets. It doesn’t supply a full annotation authoring or document editing UI.

    Nutrient Web SDK supplies those tools with a configurable UI and commercial licensing. Try the interactive demo with documents representative of your workflow before choosing an approach.

    For related integrations, see our React PDF viewer tutorial and Angular PDF viewer tutorial.

    FAQ

    How do I display a PDF in Next.js App Router?

    Put a PDF in public, render it with React-PDF’s Document and Page components, and dynamically import the viewer with ssr: false from a Client Component. Configure a matching PDF.js worker.

    Is React-PDF the same as @react-pdf/renderer?

    No. The react-pdf package displays existing PDFs. The @react-pdf/renderer package creates new PDFs from React components.

    Does adding use client prevent PDF viewer SSR errors?

    The directive alone doesn’t disable prerendering. Use dynamic() with ssr: false for React-PDF, or import a browser-only SDK inside an effect.

    Can React-PDF display annotations and forms?

    React-PDF can render existing annotations, such as links, through its annotation layer. It also has a renderForms option for form widgets. Building editing controls and saving changes requires additional implementation.

    Can Nutrient Web SDK run without Document Engine?

    Yes. Standalone viewing runs in the browser. Serve the SDK assets and document files from a web server, and configure a production license for deployment. Features that require server processing need additional services.

    Hulya Masharipov

    Hulya Masharipov

    Technical Writer

    Hulya is a frontend web developer and technical writer who enjoys creating responsive, scalable, and maintainable web experiences. She’s passionate about open source, web accessibility, cybersecurity privacy, and blockchain.

    Explore related topics

    Try for free Add a complete PDF viewer to your app