Turning React apps into PDFs with Next.js, NodeJS and puppeteer
Tags: javascript, react, css, webdev
Published: 2022-03-21 17:46:12

Hey everyone, let me preface this by saying: This is **NOT** a production ready implementation. There's several things we can implement to make this more production proof. If there's enough interest, I can make a follow-up post.

A month ago, I rebuild my resume with Next.js and Tailwindcss. Honestly, I **hate** making my resume with Word or Pages, constantly fighting spacing etc.

Knowing that React or Next.js is probably a bit overkill for just building a resume, this technique can come in handy if you for example, would have to generate invoices inside your already existing applictaion.

Oh and why Next.js? The same concept works for NodeJS and CRA, but Next.js has become my go-to boilerplate for React apps as it provides so much out of the box.

The web resume I built and export using this technique:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q4isznr3nsfe9g7nrp96.png)
[And here a link to the resulting PDF](https://cv.joko.tech/cv-jordy-koppen.pdf)

### Why?
During my initial search to generate PDFs, you quickly find out it's a lot harder than you might think. There's creating PDFs using libraries like `pdfkit` or `PDF-LIB` which looks like this:
```javascript
// pdfkit

doc
  .font('fonts/Inter.ttf')
  .fontSize(20)
  .text('Hello PDF', 100, 100)

doc
  .moveTo(100, 150)
  .lineTo(100, 250)
  .lineTo(200, 250)
  .fill('#FF3300')
```

I don't know about you, but I rather not build my resume this way.

Another very common trick is to to turn webpages into images, and in turn convert those into PDFs. Problem is is that these image PDFs don't scale when zooming in neither can you copy text, click links etc.

There's also the "Print to PDF" trick. The downside to this method is that the end user would have to manually open a page, hit print and "Print to PDF" every time you want to save it. Whilst this approach is fine if you're designing a resume with HTML and CSS, It's going to become very tedious if you are building a tool where end users need to export PDFs like invoices.

Following this guide, you will learn how to turn your React, CSS pages into PDFs together with Puppeteer!
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8emqy8i1vwvlb8vme9by.png)
Over [here](https://github.com/jordykoppen/generate-pdf-with-next) you will find the repo containing the code and the resulting [PDF](https://github.com/jordykoppen/generate-pdf-with-next/blob/main/docs/example_double.pdf)

## Requirements
Make sure you have `NodeJS` installed, I use version **16**. Basic understanding of `Next.js` and their API routes is recommended.

## Getting started
Let's start of by creating a new Next.js project by running:
```bash
npx create-next-app --ts --use-npm
```

Once the project is set up and done, let's get puppeteer installed:
```bash
npm install puppeteer 
```

Now start the dev server with `npm run dev` and clear out the standard boilerplate code inside `pages/index.tsx` etc.

## Layout
We start off by creating the Page component which will provide our A4 sized container. This will just be a simple component that renders a `div` with styling applied to mimic an A4 sized sheet. 
```typescript
// components/Page.tsx
import styles from '../styles/Page.module.css'

type Props = {
  children: React.ReactNode
}

const Page = ({ children }: Props) => (
  <div className={styles.page}>
	  {children}
  </div>
)

export default Page
```

Before we head over to our `Page` component styling, let's apply some global styling first:
```css
/* styles/global.css */

html {
  -webkit-print-color-adjust: exact; /* This makes sure that the PDF is rendered exactly like our layout. */
}

html,
body {
  padding: 0;
  margin: 0;
  background: #f1f5f9; /* Light gray background */
  width: 100%;
  height: 100%;
}

/* Next.js mounting point. Create a full width/height container for our page. */
#__next {
  height: 100vh;
  display: grid;
}

* {
  box-sizing: border-box;
}

/* Important to keep absolute as you don't want this to be rendered by the PDF. */
.downloadBtn {
  position: absolute;
  top: 0;
}
```

And for our Page styling:
```css
/* styles/Page.module.css */

.page {
  margin: auto; /* centers element within parent container */
  background: white; /* ofcourse we want our pdf background to be white */
  position: relative; /* for child elements that need absolute positioning */

  /* below is the width/height for an A4 sized sheet. For other standards lookup 
     the dimensios and apply those. */
  width: 210mm;
  height: 297mm;

  padding: 32px;
  /* optional: Add drop shadow for floating paper effect. */
  filter: drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1));
}

@page {
  size: A4;
  margin: 0;
}
```

Now let's introduce the Page component into our Home page.
```typescript
// pages/index.tsx
import type { NextPage } from 'next'
import Page from '../components/Page'

const Home: NextPage = () => {
  return (
  <>
    <Page>
      <h1>Generated PDF</h1>
      <p>This text will be in the PDF!</p>
    </Page>
  </>
  )
}

export default Home
```

If everything went correctly, it should look like:
![PDF example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8kl4130w86yhjenz4wku.png)
 

Now you have a perfect base to start generating PDFs, let's go!

## Generating PDFs with Puppeteer
For people who are not familiar with puppeteer, as per their Github page:
>Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). Puppeteer runs [headless](https://developers.google.com/web/updates/2017/04/headless-chrome) by default, but can be configured to run full (non-headless) Chrome or Chromium.

Like mentoined above, having to manually "Print to PDF" for each invoice you generate for the end user, can be rather frustrating. What if we have puppeteer do this for us in the background, and send the result back.

Let's start with creating an API route:
```typescript
// pages/api/pdf.ts
import { NextApiHandler } from 'next'
import puppeteer from 'puppeteer'

const Handler: NextApiHandler = async (req, res) => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  
  await page.goto('http://localhost:3000')
  await page.emulateMediaType('screen')

  const pdfBuffer = await page.pdf({ format: 'A4' })

  res.send(pdfBuffer)
  
  await browser.close()
}
```

##### To shortly summarize:
We created an API route called `pages/api/pdf.ts`, where we import `puppeteer` . When a call is made to `http://localhost:3000/api/pdf`, we spin up a puppeteer instance, open a new page and direct the instance to our App.
We set the media emulation mode to `screen` and start the PDF generation process.
The output of `pdf()` is a buffer which we return to the user. 
We then close down the browser instance we created and finish up our handler.

## Try it out!
You can test this out by visting `http://localhost:3000/api/pdf`. You should now see the PDF with your text/components on it!

To make this a little easier, let's include a link that will do this for us:
```typescript
<>
  <a href="/api/pdf" download="generated_pdf.pdf" className="downloadBtn">Download PDF</a>
  <Page>
    <h1>Generated PDF</h1>
    <p>As you can see you can scroll without issues and select text.</p>
  </Page>
<>
```
```css
.downloadBtn {
  position: absolute;
  top: 10px;
  left: 10px;
}
```
For the download link, we specify the `/api/pdf` route. Together with `download="FILENAME.pdf"`, we now have a clickable download link that will download the PDF for us.

Whilst we're at it, might as well try out another page!
```typescript
<>
  <a href="/api/pdf" download="generated_pdf.pdf" className="downloadBtn">Download PDF</a>
  <Page>
    <h1>Generated PDF</h1>
    <p>As you can see you can scroll without issues and select text.</p>
  </Page>
  <Page>
    <h1>Page 2</h1>
    <p>As you can see you can scroll without issues and select text.</p>
  </Page>
</>
```

## Limitations
I'll mention it again: This is not ready for production purposes. Adding elements out and around your Page component will result in botched PDFs. This due to the layout no longer being your A4 page only.
I've solved this in other projects by using styling and conditions which in the end still look very elegant and simple.

If you are interested in a follow-up, proofing the implementation for production or have any questions please let me know!