DEV Community

Jordy Koppen
Jordy Koppen

Posted on • Updated on

Turning React apps into PDFs with Next.js, NodeJS and puppeteer

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
And here a link to the resulting 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:

// 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')
Enter fullscreen mode Exit fullscreen mode

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
Over here you will find the repo containing the code and the resulting 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:

npx create-next-app --ts --use-npm
Enter fullscreen mode Exit fullscreen mode

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

npm install puppeteer 
Enter fullscreen mode Exit fullscreen mode

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.

// 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
Enter fullscreen mode Exit fullscreen mode

Before we head over to our Page component styling, let's apply some global styling first:

/* 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;
}
Enter fullscreen mode Exit fullscreen mode

And for our Page styling:

/* 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;
}
Enter fullscreen mode Exit fullscreen mode

Now let's introduce the Page component into our Home page.

// 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
Enter fullscreen mode Exit fullscreen mode

If everything went correctly, it should look like:
PDF example

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. Puppeteer runs headless 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:

// 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()
}
Enter fullscreen mode Exit fullscreen mode
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:

<>
  <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>
<>
Enter fullscreen mode Exit fullscreen mode
.downloadBtn {
  position: absolute;
  top: 10px;
  left: 10px;
}
Enter fullscreen mode Exit fullscreen mode

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!

<>
  <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>
</>
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (27)

Collapse
 
ecyrbe profile image
ecyrbe • Edited

You should take a look at react-pdf.

It works in nodejs as well as in browsers.

It's ultra light, a lot faster than using puppeteer.

Edit: fix URL

Collapse
 
agnel profile image
Agnel Waghela • Edited

Currently your link to react-pdf points to 'https%20//react-pdf.org'. I think it should be npmjs.com/package/react-pdf.

Also, this post is about generating PDFs whereas react-pdf is meant for displaying existing PDFs only.

If you meant to point at @react-pdf/renderer, then too, using puppeteer seems better, as you cannot render custom react components within the pdf using @react-pdf/renderer.

Collapse
 
ecyrbe profile image
ecyrbe • Edited

Actually, no. React pdf Can generate pdf on fly. I use it on production on Big applications since more than 2 years.
It's not meant to render existing pdf at all.
Take a Closer look at the documentation.

You can of course create and render custom components with react-pdf. We do it all the time. What you can't do, is use react components that target the browser DOM.

Thread Thread
 
agnel profile image
Agnel Waghela

The updated link is pointing to the official website for the npm package @react-pdf/renderer. This does have a feature to generate a pdf on the fly, no doubt. It does support PDF generation, unlike the npm package react-pdf.

I wasn't successful in using mui with @react-pdf/renderer. May be I'm missing something.

Thread Thread
 
ecyrbe profile image
ecyrbe • Edited

Mui is only targetting the DOM. So it can not work. I said you can use custom components, but not use ones that target the DOM.

Let me expand on why i suggest anyone to use react-pdf instead of react+pupppeteer.

What are the issues with puppeteer

The author made some disclaimers about his solution not being production ready. And it's for a good reason. Not just because of paginations issues.

  • puppeeter is executed on backend only.
  • It does not scale : one instance of pdf generating will use at least 2GO and more
  • one instance will use a lot of CPU
  • huge PDF size: generating documentation for hundreds of pages is not practical as you end up with pdf size that no sane user will want to download
  • rendering your pdf will be slow
  • no interactive pdf because your pdf are based on images, not actual searchable text in pdf, no clickable links...
  • you will not be able to control pagination without trial and error.

What are the advantages of using puppeteer

  • you develop your components only once
  • you will be able to set it up fast. in fact, you can set this up in a day.

What are the issues with react-pdf

  • if you need to share components with the DOM and react-pdf :
    • you'll need to implement your own components that targets both the DOM and PDF (aka : TextField, CheckBox, etc). i would not recommend that path. A lot of DOM components do not make sense in the pdf world (ie: Search bar, menu, etc)
  • you'll need more time than puppeteer solution as you'll need to create custom components that match your design.

What are the advantages of using react-pdf

  • ultra light (only a few megabytes of RAM)
  • blazing fast pdf rendering
  • works on both frontend and backend
  • scale to serve thousands/hundreds of thousands users , even in severless environments
  • small generated PDF size (few Kbytes)
  • interactive pdf with searchable text, links, pdf annotations
  • pagination out of the box that just work

From an engineering point of view, using puppeteer for a small project that have really few users is ok, because you'll not have to invest too much of your time. But for large scaling projects, i would recommand using react-pdf.

Thread Thread
 
agnel profile image
Agnel Waghela

Thank you @ecyrbe for elaborating on the issues and the advantages.

Collapse
 
biomathcode profile image
Pratik sharma

I would recommend that too!

Collapse
 
abrahammzansie profile image
Abraham Mzansie Nkomo

HI , Can l use react-pdf on node js backend as well ? . If yes please show me how

Collapse
 
ecyrbe profile image
ecyrbe

Yes you can.
Take a look at the docs. On backend, use renderToStream to generate pdf on the fly.

Thread Thread
 
abrahammzansie profile image
Abraham Mzansie Nkomo

l checked the documentation and l still do not understand how to generate on backend side . Please @ecyrbe assist me

Collapse
 
khadetou profile image
khadetou

Does it work on nextjs though ?

Collapse
 
ecyrbe profile image
ecyrbe

Yes, no worries.

Collapse
 
luudv profile image
Luu Dao

Current version doesn't seem to support react 18 and next 1.3, I tried but it deosn't work.

Collapse
 
hassnainabass profile image
Hassnain Abass • Edited

i am using phantomjs always to generate pdf, built a service in nodejs, puppeteer and Phantomjs which i use on all my apps, Nice write +1

Collapse
 
elias123tre profile image
Elias

Phantomjs is nice although it is no longer maintained, an alternative is playwright.dev which I like very much :)

Collapse
 
neeshsamsi profile image
Neesh Samsi

This is a great article. I was stuck trying to figure this out, I almost succeeded trying to use react server to render a component to html and then use some html to pdf rendered but then the tailwind styles weren't being applied. This is a life saver. Would absolutely love a follow up for a production ready implementation with next & tailwind!

Collapse
 
cednore profile image
cednore

Actually, I used to rely on puppeteer in production level to generate the PDF files based on the content we have on our product. Also, we did see some of limitations you mentioned here, especially we we had to deal with different page sizes on a single document. Nice explanation btw, Kudos!

Collapse
 
donovanh profile image
Donovan

Oh wow, this is just what I was looking for. Nice write up too +1

Collapse
 
vjr2nd profile image
Vijay Roy 🇨🇮

Never seen such easy explanation before. keep it up bro 👍

Collapse
 
jordykoppen profile image
Jordy Koppen

Thanks, I appreciate it!

Collapse
 
skruzic1 profile image
Stanko Kružić

Hi,
the article is great, however, it seems that generated PDF is not searchable and the text in it is not selectable. However, chromium (and puppeteer for that matter) by default produces searchable PDF. Do you know how to resolve this? Thanks!

Collapse
 
katiefuwang profile image
Katie / World Class Code

My resume looks identically to yours? I used React, not Next JS. When printing to PDF I just hit CMD+P on my mac and click Print to PDF. That's it. It does not do page breaks very well... perhaps I can have that with your code? Can we have the source code?

Collapse
 
katiefuwang profile image
Katie / World Class Code

I found your repo. How about page breaks? Your CV is only one page :)

Collapse
 
dspsolves profile image
Dhawal Singh Panwar

Concisely explained. ⭐

Though, I've faced issues deploying a puppeteer based solution in Docker. Stackoverflow-ing my way, I found out that you need to build the package in the Dockerfile's commands and then use it somehow.

I'm showing interest in discussing the how-tos on making this production ready and make it work as a separately deployed microservice. ✌🏻

Collapse
 
jordykoppen profile image
Jordy Koppen

Thank! I’ll see what I can come up with for a next post!

Collapse
 
nxmxgoldxx profile image
Calenté Cardwell

Is this posted in a repository ? We think we are overthinking it and erasing components we actually needed.

Collapse
 
ajinspiro profile image
Arun Kumar

Thanks for sharing..!!
Also thanks for the insights @ecyrbe!!