Generate PDFs in NestJS

Add a PDF download to an existing NestJS page, or generate a PDF from a template. This guide uses PDFCrowd's HTML to PDF API with Nest controllers, preserving the application's response and exception handling.

Set up the client

Install the Node.js client in your NestJS application. The examples use 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

An existing page can display its usual HTML and return a PDF when the user clicks a download button. Reuse the page's template, data preparation, and access checks.

Choose what to send to PDFCrowd:

Convert rendered HTML

These TypeScript examples use Nest's Express adapter and EJS templates in views/. Use your existing view engine and directory if they differ. For EJS, install ejs and configure your NestExpressApplication with setViewEngine('ejs') and setBaseViewsDir(...) pointing to the views directory.

Here, DocumentsService.getForCurrentUser(req, documentId) represents your application's asynchronous lookup and permission checks. Replace it with your own service method; it should reject missing or inaccessible documents. The EJS template at views/documents/detail.ejs receives the result as document.

Keep the controller registered in its Nest module, with your existing authentication guards and CSRF middleware. The example uses Nest's normal form-body parsing.

import {
    Controller, Get, HttpCode, NotFoundException, Post, Req, Res,
    StreamableFile,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { HtmlToPdfClient } from 'pdfcrowd';
import { DocumentsService } from './documents.service.js';

@Controller('documents')
export class DocumentsController {
    constructor(private readonly documents: DocumentsService) {}

    @Get(':documentId')
    getPage(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
        return this.documentDetail(req, res);
    }

    @Post(':documentId')
    @HttpCode(200)
    postPage(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
        return this.documentDetail(req, res);
    }

    private async documentDetail(req: Request, res: Response) {
        try {
            const documentId = req.params.documentId;
            if (typeof documentId !== 'string' || !/^\d+$/.test(documentId)) {
                throw new NotFoundException();
            }

            const document = await this.documents.getForCurrentUser(req, documentId);
            const renderPage = () => new Promise<string>((resolve, reject) => {
                res.render('documents/detail', { document }, (error, html) => {
                    if (error) reject(error);
                    else resolve(html);
                });
            });
            if (req.method !== 'POST' || req.body?.download_pdf !== '1') {
                return await renderPage();
            }

            const html = await renderPage();
            const client = new HtmlToPdfClient('demo', 'demo');
            client.setContentViewportWidth('balanced');
            const pdf = await client.convertString(html); // Buffer

            return new StreamableFile(pdf, {
                type: 'application/pdf',
                disposition: `attachment; filename="document-${documentId}.pdf"`,
            });
        } catch (error) {
            // Add your application's error handling here.
            throw error;
        }
    }
}

Add this form to the EJS template, outside any existing form:

<form method="post" class="pdfcrowd-remove">
    <!-- Keep your application's CSRF token field here. -->
    <button type="submit" name="download_pdf" value="1">Download PDF</button>
</form>

Keep your application's CSRF token field and its validation on the POST route; the comment above is a placeholder for that field. The pdfcrowd-remove class hides the form in the PDF while leaving it visible on the webpage.

GET requests and unmarked POSTs display the HTML page. Submitting the download button renders the same template, converts it, and returns a PDF. The callback form of res.render() obtains HTML without sending a response, preserving application and request locals. The small Promise wrapper lets the controller await this rendering step.

await client.convertString(html) returns the complete PDF as a Buffer held in memory. StreamableFile then lets Nest send it as a file response; conversion is already complete. passthrough: true keeps Nest responsible for sending the returned HTML or PDF, including its normal interceptor processing. @HttpCode(200) sets the download status instead of Nest's default 201 for POST.

The catch block rethrows lookup, rendering, and conversion errors to Nest's exception handling. No PDF headers or bytes have been sent at that point. Keep client-facing errors generic and SDK details in server logs; do not use SDK error codes as HTTP response statuses. See SDK error handling for details.

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

Make CSS and images available

PDFCrowd needs reachable URLs for stylesheets, images, and fonts referenced by the rendered HTML. Absolute asset URLs, such as https://your-site.example/css/document.css, need no additional base URL.

For relative paths, set the public site or directory URL during application setup. Here, app is your existing NestExpressApplication:

const expressApp = app.getHttpAdapter().getInstance();
expressApp.locals.pdfBaseUrl = 'https://your-site.example/';

Replace https://your-site.example/ with your website's public URL. Then add this to the EJS template's <head>, before stylesheet links:

<% if (typeof pdfBaseUrl !== 'undefined' && pdfBaseUrl) { %>
<base href="<%= pdfBaseUrl %>">
<% } %>

Express makes these application locals available to both res.render() and app.render(). The <base> therefore applies whenever this template is rendered, including the normal HTML page. It sets the base for relative assets and hyperlinks: css/document.css becomes https://your-site.example/css/document.css. For assets beneath a directory, use a base such as https://your-site.example/app/, including the trailing slash. Paths starting with / still resolve from the site's root. Avoid adding a second <base> if one already exists.

PDFCrowd cannot reach assets on your development machine through localhost or 127.0.0.1. 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.

Convert the page URL instead

Instead of sending rendered HTML, you can ask PDFCrowd to open the page through its URL. First configure your public origin during Nest application setup, without a path or trailing slash:

app.getHttpAdapter().getInstance().set('publicOrigin', 'https://your-site.example');

Keep the lookup, POST check, ordinary HTML response, client setup, error handling, and PDF response above. Remove const html = await renderPage(); from the PDF branch and replace the convertString() line with:

const url = req.app.get('publicOrigin') + req.originalUrl;
const pdf = await client.convertUrl(url); // Buffer

This preserves the current path and query parameters while keeping the origin under your application's control. PDFCrowd makes a separate GET request, so the route returns HTML; only the original POST triggers conversion. The server must be able to handle that GET while the POST waits for the PDF. The page and its assets must be reachable from PDFCrowd.

The separate request does not inherit the user's session. For protected pages, configure cookies or HTTP authentication as appropriate, or use rendered HTML from the original request. If your session middleware locks the session until the POST finishes, forwarding that session can block the GET; use rendered HTML instead.

Generate a PDF from a template

You can also generate a PDF directly from a template and application data, without an existing HTML page. Use an existing EJS template or a separate template designed for the document.

This function uses the configured Express application beneath Nest to render the template and returns a Promise for a PDF Buffer. It uses the same asset configuration as the controller example:

import type { Application } from 'express';
import { HtmlToPdfClient } from 'pdfcrowd';

export async function renderPdf(
    expressApp: Application, template: string, context: Record<string, unknown>,
): Promise<Buffer> {
    const html = await new Promise<string>((resolve, reject) => {
        expressApp.render(template, context, (error, result) => {
            if (error) reject(error);
            else resolve(result);
        });
    });
    const client = new HtmlToPdfClient('demo', 'demo');
    client.setContentViewportWidth('balanced');
    return client.convertString(html);
}

For example, given a document, call it from an async controller method:

try {
    const pdf = await renderPdf(req.app, 'documents/pdf', { document }); // Buffer
    // Use the PDF for a download, storage, or email.
} catch (error) {
    // Add your application's error handling here.
    throw error;
}

Here, req is the Express request injected with @Req(). Create views/documents/pdf.ejs for the document layout and pass all its data in context. Application locals are included; request locals are not, so supply any values the template needs from them explicitly. Choose template names and context keys in application code; do not pass req.query or req.body directly as render options.

The helper sends no HTTP response. Its caller receives rendering or conversion failures as Promise rejections. Outside a request, pass app.getHttpAdapter().getInstance() from your configured Nest application and use a template that does not depend on request-local values, such as session data or the download form's CSRF token.

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