Nextjs: Generate PDF's on Vercels Serverless Functions using Puppeteer

May 5, 2024

Objective: PDF Generation with Serverless Functions

Run puppeteer on Vercel's Serverless Functions and, at the same time a generate PDF.

Understanding the Error 500: The Limitations of Chromium on AWS Lambda

Running puppeteer on Vercel will return an Error 500 because puppeteer relies on a version of Chromium which is too large to be included on a AWS Lambda.

Optimizing Puppeteer for Vercel: A Lightweight Solution

Vercel's Serverless Functions are powered by AWS Lambdas, which have a 50MB limit on the deployment package size. Chromium, which is required by Puppeteer, is too large to be included in our Lambda function.

In order for Puppeteer to be included, a smaller version of Chromium is required.

  • puppeteer-core instead of puppeteer on production
  • @sparticuz/chromium.

The Solution

Getting Started: Essential Packages for Serverless PDF Creation

# Puppeteer
pnpm add @sparticuz/chromium@123.0.1
pnpm add puppeteer-core@21.9.0
pnpm add -D puppeteer@21.9.0

# Implement a PDF
pnpm add @onedoc/react-print

Configuration: Setting Up Puppeteer-Core with Custom Chromium

I have created a Server Action, but feel free to use it on a route handler.

export async function makdePDF(html: string) {
  // Initiate the browser instance
  let browser: Browser | undefined | null;

  // Check if the environment is development
  if (process.env.NODE_ENV !== "development") {
    // Import the packages required on production
    const chromium = require("@sparticuz/chromium");
    const puppeteer = require("puppeteer-core");

    // Assign the browser instance
    browser = await puppeteer.launch({
      executablePath: await chromium.executablePath(),
      headless: chromium.headless,
      ignoreHTTPSErrors: true,
      defaultViewport: chromium.defaultViewport,
      args: [...chromium.args, "--hide-scrollbars", "--disable-web-security"],
    });
  } else {
    // Else, use the full version of puppeteer
    const puppeteer = require("puppeteer");
    browser = await puppeteer.launch({
      headless: "new",
    });
  }

  // Create a PDF
  if (browser) {
    const page = await browser.newPage();
    await page.setContent(html);

    const pdfBuffer = await page.pdf({
      format: "a4",
      printBackground: true,
      margin: {
        top: 80,
        bottom: 80,
        left: 80,
        right: 80,
      },
    });

    await browser.close();

    return pdfBuffer;
  }

  return null;
}

Crafting PDFs: Serverless Document Creation

Follow the tutorial on React Print.

export function PdfCv() {
  return (
    <Tailwind>
      <div className="p-2 md:p-6">
        <p className="text-xs font-semibold leading-7 text-indigo-600">
          {cv.title}
        </p>
        <h1 className="mt-2 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
          {cv.name}
        </h1>
        // ...
      </div>
    </Tailwind>
  );
}

Facilitating Downloads: Building a Route for Serverless PDF Downloads

In order to make the PDF available for download, a route handler is required to be created with the appropriate headers. I personally created the route handler in the /app directory following the below structure:

- /app
    - /cv
        - page.tsx
        - /download
            - route.tsx

The following snippet is the route handler for downloading the PDF:

// /download/route.tsx
import { compile } from "@onedoc/react-print";
import { PdfCv } from "../_components/pdf-cv";
import { makePDF } from "../actions/make-pdf";
import postcss from "postcss";
import theme from "../../../tailwind.config";
import path from "path";
import fs from "fs";

async function importTailwindGlobals() {
  const cssPath = path.join(__dirname, "../../../../../app/globals.css");
  try {
    const cssContent = fs.readFileSync(cssPath, "utf8");
    return cssContent;
  } catch (error) {
    console.error("Error reading the Tailwind Globals css file:", error);
  }
}

export async function GET(request: Request) {
  try {
    // Create a new header
    const headers = new Headers();

    // Compile tailwind css
    const css = await postcss([
      require("tailwindcss")({
        ...theme,
        corePlugins: { preFlight: false },
      }),
    ])
      .process(`${globals}`, { from: undefined })
      .then((result) => result.css);

    // Compile the PDF
    const html = await compile(
      <div>
        <style dangerouslySetInnerHTML={{ __html: css }} />
        <PdfCv mode={mode} />
      </div>
    );

    // Create a PDF buffer
    const pdfBuffer = await makePDF(html);

    // Assign PDF header as content-type
    headers.append("Content-Type", "application/pdf");

    // Assign the disposition header as attachment
    headers.append(
      "Content-Disposition",
      `attachment; filename="nabil-benhamou-resume-${new Date().getFullYear()}.pdf"`
    );

    // Return the PDF buffer
    return new Response(pdfBuffer, { headers });
  } catch (error) {
    return new Response("Error downloading PDF", {
      status: 500,
      headers: {
        "Content-Type": "text/plain",
      },
    });
  }
}

Nextjs Configuration: Exclude Puppeteer and Chromium

Because puppeteer-core and @sparticuz/chromium are used inside Server Components and Route Handlers they will automatically be bundled by Next.js.

Therefore they require to be marked as external in the RSC server build.

const nextConfig = {
  //   ...
  serverExternalPackages: ["puppeteer-core", "@sparticuz/chromium"],
  //   ...
};

Related Reading