Generate PDFs in Next.js

Add a PDF download to an existing Next.js page, or generate a PDF from a template. This guide uses PDFCrowd's HTML to PDF API from App Router route handlers, keeping conversion on the server and React responsible for the page.

Set up the client

Install the Node.js client in your Next.js application. The examples use Next's default Node runtime and the async/await interface available in SDK 7 and later:

npm install pdfcrowd

You can run the examples below with the included demo credentials, or replace them with your PDFCrowd username and API key.

Add a PDF download to an existing page

Keep the page's normal rendering, data preparation, and access checks. A download form sends a POST to a separate route, which asks PDFCrowd to open the page's URL and returns the resulting PDF.

The page and its assets must be reachable from PDFCrowd. For documents that do not have a publicly reachable page, use the template approach below.

Add the download form

Suppose your page at app/documents/[documentId]/page.tsx displays a document with a numeric id. Configure the public application URL in lib/pdf-config.ts:

export const publicBaseUrl = 'https://your-site.example/';

Replace the URL with your own, including its trailing slash. If your application uses a base path, include it here, for example https://your-site.example/portal/.

Import publicBaseUrl from @/lib/pdf-config in the page and add this form outside any existing form:

<form
    method="post"
    action={new URL(`documents/${document.id}/pdf`, publicBaseUrl).pathname}
    className="pdfcrowd-remove"
>
    <input type="hidden" name="csrf_token" value={csrfToken} />
    <button type="submit">Download PDF</button>
</form>

Here, csrfToken represents your application's CSRF token. Use its actual field name and token generation. The handler below must validate the same token. The form action keeps the configured base path, and pdfcrowd-remove hides the form in the PDF.

Return the PDF

Create app/documents/[documentId]/pdf/route.ts. A page and a route handler cannot share the same route, so this endpoint sits beneath the existing document page.

The imported getDocumentForCurrentUser(request, documentId) is a placeholder for your document lookup and permission checks; it should return null for missing or inaccessible documents. verifyCsrfToken(request, form) represents your existing POST protection and returns whether the request is valid. Replace both imports with your application's logic, retaining its authentication checks.

import { HtmlToPdfClient } from 'pdfcrowd';
import { publicBaseUrl } from '@/lib/pdf-config';
import { getDocumentForCurrentUser } from '@/lib/documents';
import { verifyCsrfToken } from '@/lib/csrf';

export async function POST(
    request: Request,
    { params }: { params: Promise<{ documentId: string }> },
) {
    try {
        const form = await request.formData();
        if (!await verifyCsrfToken(request, form)) {
            return new Response('Forbidden', { status: 403 });
        }

        const { documentId } = await params;
        if (!/^\d+$/.test(documentId)) {
            return new Response('Not found', { status: 404 });
        }
        const document = await getDocumentForCurrentUser(request, documentId);
        if (!document) {
            return new Response('Not found', { status: 404 });
        }

        const url = new URL(`documents/${documentId}`, publicBaseUrl);
        const client = new HtmlToPdfClient('demo', 'demo');
        client.setContentViewportWidth('balanced');
        const pdf = await client.convertUrl(url.href); // Buffer

        return new Response(new Uint8Array(pdf), {
            headers: {
                'Content-Type': 'application/pdf',
                'Content-Disposition': `attachment; filename="document-${documentId}.pdf"`,
            },
        });
    } catch (error) {
        // Add your application's error handling and logging here.
        return new Response('PDF generation failed', { status: 500 });
    }
}

The handler creates a client for each conversion and waits for the complete PDF Buffer. Uint8Array supplies its binary bytes to the Web Response API. Conversion failures reach the catch block before any PDF headers or bytes are sent. Keep SDK details in server logs; do not use SDK error codes as HTTP response statuses. See SDK error handling for details.

The page URL comes from your configured base URL and the validated document ID. If the page uses query parameters, add only the values your application needs through url.searchParams; the handler does not forward arbitrary URLs or request parameters.

To display the PDF in the browser, change attachment to inline. Set page size, margins, or other PDF options before convertUrl().

What PDFCrowd loads

PDFCrowd makes a separate GET request to the document page, which renders through Next.js as usual. That GET does not run the POST download handler. The application must be able to serve the page while the download request waits for conversion.

The separate request does not inherit the browser's session. For protected pages, configure cookies or HTTP authentication as appropriate, or generate a PDF from the authorized data using the template approach below.

Stylesheets, images, fonts, and scripts used by the page must also be reachable from PDFCrowd. It cannot access your development machine through localhost or 127.0.0.1. URL conversion opens a fresh page, so it does not capture unsaved changes in the user's browser. For that use case, see PDF export in reactive apps with WebSave as PDF.

Generate a PDF from a template

You can generate a PDF from a template and application data without an existing HTML page. This example uses EJS to produce document HTML separately from Next's React page rendering. You can substitute an HTML renderer your application already uses.

Install EJS and the marker used to keep the helper in server code:

npm install ejs server-only
npm install --save-dev @types/ejs

Put a document template in lib/document-template.ts:

export const documentTemplate = `<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <base href="<%= publicBaseUrl %>">
    <title><%= document.title %></title>
</head>
<body>
    <h1><%= document.title %></h1>
    <p><%= document.body %></p>
</body>
</html>`;

The <%= tags escape inserted values as HTML. Keeping the template in a module includes it in the application build; no template file needs to be loaded from disk at runtime.

Create lib/render-pdf.ts:

import 'server-only';
import ejs from 'ejs';
import { HtmlToPdfClient } from 'pdfcrowd';

export async function renderPdf(
    template: string, context: Record<string, unknown>,
): Promise<Buffer> {
    const html = ejs.render(template, context);
    const client = new HtmlToPdfClient('demo', 'demo');
    client.setContentViewportWidth('balanced');
    return client.convertString(html);
}

The server-only import makes Next reject accidental imports of this helper into Client Components. Call it from an async server function with your document data:

import { documentTemplate } from '@/lib/document-template';
import { publicBaseUrl } from '@/lib/pdf-config';
import { renderPdf } from '@/lib/render-pdf';

// Inside an async server function, after loading and authorizing the document:
try {
    const pdf = await renderPdf(documentTemplate, { document, publicBaseUrl }); // Buffer
    // Use the PDF for a download, storage, or email.
} catch (error) {
    // Add your application's error handling here.
    throw error;
}

The helper sends no HTTP response and does not read request state. Rendering or conversion failures reject its Promise. Choose the template and context keys in application code; do not use user-supplied template source or pass request parameters directly as render options.

Make CSS and images available

The template's <base> uses the same publicBaseUrl configured above. It sets the base for relative assets and hyperlinks: with https://your-site.example/portal/, css/document.css resolves to https://your-site.example/portal/css/document.css. Paths starting with / still resolve from the site's root. Keep the trailing slash and place <base> before stylesheet links; do not add a second one to an existing template.

Use reachable URLs for stylesheets, images, and fonts. The template does not automatically include the Next.js page's styles or layout. If you use only inline content and absolute asset URLs, you can omit <base> and its context value.

For local assets, you can instead package the HTML and assets in a ZIP archive. Keep paths relative to the archive's folders and omit the website <base> for that approach.

For more conversion inputs and PDF settings, see the Node.js examples and API reference.