How to build an HTML to PDF app in 5 minutes
Tags: nextjs, webdev, programming, javascript
Published: 2024-05-25 09:33:23

Today, I will explain how to create an app to convert HTML into PDFs with just a simple logic. Without using any external library!

We will use BuildShip for creating APIs, a platform that allows you to visually create backend cloud functions, APIs, and scheduled jobs, all without writing a single line of code.

We will do it in two steps:

1. Making API in BuildShip.
2. Creating a frontend app and the complete integration.

Let's do it.

---

## 1. Making API in BuildShip.

Login on [BuildShip](https://buildship.com/) and go to the dashboard. This is how it looks!

![dashboard](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/utl8qwmrn12wruo9z8dp.png)

There are a lot of options available which you can explore yourself. For now, you need to create a new project.

You can add a new trigger by clicking on the "Add Trigger" button. For this example, we will use a REST API call as our trigger. We specify the path as "HTML-to-PDF" and the HTTP method as POST.

![trigger](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ffssgxvg9hsyg9ifri9b.png)

![rest api trigger](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i351cv1pfwhf9h1x9rk6.png)

We have to add a few nodes to our workflow. BuildShip offers a variety of nodes and for this example, we will be adding the `UUID Generator node`, followed by the `HTML to PDF` node to our workflow.

![html to pdf and uuid node](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ts8wn99t9p0uji01iv7k.png)

If you're wondering, The UUID Generator node generates a unique identifier, and we are going to use this as the name for our generated file to be stored.

Now, we can add the "HTML to PDF" node. This node has three inputs: 

- the HTML content.
- Options to configure the returned PDF.
- the file path to store the generated PDF file.

![html to pdf node](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pr9qjwwjprxn1t16ibk0.png)

Keep the values as shown in the image below (text would be confusing).


![html content](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6gvcqgdqkzvezxxtx2fe.png)
<figcaption>HTML Content</figcaption>

&nbsp;

![Options](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fx1m3zq2b1shpfyttcaf.png)
<figcaption>Options</figcaption>

&nbsp;

![file path](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j6qepffkdh6m8fd91gpy.png)
<figcaption>We will keep the path file as UUID so it's unique</figcaption>

&nbsp;

The options are clear on their own and appropriate for the first time.

Now you just need to add a node `Generate Public Download URL` which generates a publicly accessible download URL from Buildship's Google Storage Cloud storage file path, and finally a `return node`. 

![more nodes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0qp9djfbzadxvusnnjbh.png)

There is also an option of checking the logs which could help you to understand the problem later on.

![logs](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eii1q80bfvxfzmkqacuq.png)

For the sake of testing, you can click on the `Test` button and then `Test Workflow`. It works correctly as shown!

![final testing](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o5b1he79shl11uolosw0.png)

Once everything is done, and the testing is completed. We are ready to ship our backend to the cloud. Clicking on the `Ship` button deploys our project and will do the work.

Now, you can copy your endpoint URL and test it in Postman or any other tool you prefer. It will definitely work. 

Make a `POST` request and put the following sample in the raw body.

```
{
  "html": "<html><body><h1>hey there what's up buddy</h1></body></html>"
}
```

As you can see below, it works fine. But showcasing this is a big problem. So, we're going to build a fantastic frontend to implement it.

![api testing in postman](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/andbuf9legte5aujmy4a.png)

---

## 2. Creating a frontend app and the complete integration.

I will be using Next.js + Tailwind + TypeScript for the frontend.
I have attached the repo and deployed link at the end.

I'm using [this template](https://github.com/Anmol-Baranwal/Nextjs-TypeScript-Tailwind-Template), which has proper standards and other stuff that we need. I have made it myself, and you can read the readme to understand what is available.

Directly use it as a template for creating the repo, clone it, and finally install the dependencies using the command `npm i`.

I'm not focusing on accessibility otherwise I would have used Shadcn/ui.

Let's start building it.

Create a Button component under `components/Button.tsx`.

```typescript
import React, { FC, ReactNode, MouseEventHandler } from 'react'
import { Icons } from './icons'

interface ButtonProps {
  onClick: MouseEventHandler<HTMLButtonElement>
  children: ReactNode
}

const Button: FC<ButtonProps> = ({ onClick, children }) => (
  <button
    onClick={onClick}
    className="flex items-center justify-center rounded-md border-2 border-text-100 bg-black px-8 py-4 text-white transition-all duration-300 hover:bg-black/90"
  >
    {children}
    <Icons.download className="ml-2 h-4 w-4 text-white" />
  </button>
)

export default Button
```

The icons component will already be there in the template, so you just need to download the icons or attach your own SVG (the code is already there).

![icons component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u129uhpjlx5vykqttx0k.png)

Let's create the main `page.tsx` under `src/app`.

```typescript
'use client'

import { useState } from 'react'
import Button from '@/components/Button'
import Link from 'next/link'
import { sampleHtml } from '@/data/sampleHtml'

export default function HTML2PDF() {
  const [isLoading, setIsLoading] = useState(false)
  const [fetchUrl, setFetchUrl] = useState('')
  const [htmlCode, setHtmlCode] = useState('')

  const handleSampleCodeClick = () => {
    setHtmlCode(sampleHtml)
  }

  const handleConvertClick = async () => {
    setIsLoading(true)
    try {
      const response = await fetch('https://pjdmuj.buildship.run/html-to-pdf', {
        method: 'POST',
        body: JSON.stringify({ html: htmlCode }),
        headers: {
          'Content-Type': 'application/json',
        },
      })
      const data = await response.text()
      setFetchUrl(data)

      console.log({ data })
    } catch (error) {
      console.error('Error converting HTML to PDF:', error)
    }
    setIsLoading(false)
  }

  return (
    <div className="flex h-screen items-center justify-center pt-0">
      <div className="flex w-full flex-col items-center justify-center space-y-1 dark:text-gray-100">
        <h1 className="bg-gradient-to-r from-black to-gray-500 bg-clip-text pb-3 text-center text-3xl font-bold tracking-tighter text-transparent md:text-7xl/none">
          HTML to PDF Converter
        </h1>
        <p className="sm:text-md mx-auto max-w-[650px] pb-1 pt-1 text-gray-600 md:py-3 md:text-xl lg:text-2xl">
          Paste the html code and convert it.
        </p>
        <p
          className="text-md mx-auto cursor-pointer pb-6 text-[#A855F7] underline"
          onClick={handleSampleCodeClick}
        >
          Use sample code
        </p>
        {isLoading ? (
          <div className="flex items-center justify-center">
            <div className="loader"></div>
          </div>
        ) : (
          <div className="flex w-80 flex-col items-center justify-center">
            <textarea
              value={htmlCode}
              onChange={(e) => setHtmlCode(e.target.value)}
              className="mb-4 w-full rounded-lg border border-gray-400 p-2 shadow-sm shadow-black/50"
              placeholder="Paste HTML code here"
              rows={8}
            />
            {fetchUrl ? (
              <div className="mt-4">
                <Link
                  target="_blank"
                  href={fetchUrl}
                  className="w-40 rounded-md bg-black px-8 py-4 text-white transition-all duration-300 hover:bg-black/90"
                  download
                >
                  Download PDF
                </Link>
              </div>
            ) : (
              <div className="mt-4">
                <Button onClick={handleConvertClick}>Convert to PDF</Button>
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  )
}
```

> The output.

![output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q38j17tq26yr2is3op7k.png)

The code is self-explanatory but let's break it down.

There is a loading state to show an SVG when the request is handled on the backend, `fetchUrl` for the final url of the PDF, and the `htmlCode` that will be used as a body for the API request. 

```javascript
  const [isLoading, setIsLoading] = useState(false)
  const [fetchUrl, setFetchUrl] = useState('')
  const [htmlCode, setHtmlCode] = useState('')
```

I have imported sample data from `data/sampleHtml.ts` so that a user can directly check the functionality by clicking on `use sample code`.

```
// sampleHtml.ts

export const sampleHtml = `<html>
  <body>
    <h1>Hey there, I'm your Dev.to buddy. Anmol!</h1>
    <p>You can connect me here.</p>
    <p><a href='https://github.com/Anmol-Baranwal' target='_blank'>GitHub</a> &nbsp; <a href='https://www.linkedin.com/in/Anmol-Baranwal/' target='_blank'>LinkedIn</a> &nbsp; <a href='https://twitter.com/Anmol_Codes' target='_blank'>Twitter</a></p>
  </body>
</html>
`
``` 

```
use it on the page.tsx

import { sampleHtml } from '@/data/sampleHtml'
 
...
const handleSampleCodeClick = () => {
    setHtmlCode(sampleHtml)
  }

...
<p className="text-md mx-auto cursor-pointer pb-6 text-[#A855F7] underline" onClick={handleSampleCodeClick} > Use sample code </p>
```

The API request is sent when the button is clicked.

```typescript
const handleConvertClick = async () => {
    setIsLoading(true)
    try {
      const response = await fetch('https://pjdmuj.buildship.run/html-to-pdf', {
        method: 'POST',
        body: JSON.stringify({ html: htmlCode }),
        headers: {
          'Content-Type': 'application/json',
        },
      })
      const data = await response.text()
      setFetchUrl(data)

      console.log({ data })
    } catch (error) {
      console.error('Error converting HTML to PDF:', error)
    }
    setIsLoading(false)
  }


....
{isLoading ? (
  <div className="flex items-center justify-center">
    <div className="loader"></div>
  </div>
) : (
  <div className="flex w-80 flex-col items-center justify-center">
    <textarea
      value={htmlCode}
      onChange={(e) => setHtmlCode(e.target.value)}
      className="mb-4 w-full rounded-lg border border-gray-400 p-2 shadow-sm shadow-black/50"
      placeholder="Paste HTML code here"
      rows={8}
    />
    {fetchUrl ? (
      <div className="mt-4">
        <Link
          target="_blank"
          href={fetchUrl}
          className="w-40 rounded-md bg-black px-8 py-4 text-white transition-all duration-300 hover:bg-black/90"
          download
        >
          Download PDF
        </Link>
      </div>
    ) : (
      <div className="mt-4">
        <Button onClick={handleConvertClick}>Convert to PDF</Button>
      </div>
    )}
  </div>
)}
```

You can use `console.log` to check the data received.

![sample code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2ndmma6063raa6ah782k.png)
<figcaption>using sample code</figcaption>

&nbsp;

![final pdf](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6fy9nidb21cm17v8ulej.png)
<figcaption>final pdf</figcaption>

&nbsp;

Many developers still prefer using different useState (including myself) for the states so it's easier to address particular changes but let's optimize this further and use route handlers.

Let's change the state.

```typescript
'use client'

import { useState } from 'react'
import Button from '@/components/Button'
import Link from 'next/link'
import { sampleHtml } from '@/data/sampleHtml'

export default function HTML2PDF() {
  const [state, setState] = useState({
    isLoading: false,
    fetchUrl: '',
    htmlCode: '',
  })

  // Destructure state into individual variables
  const { isLoading, fetchUrl, htmlCode } = state

  const handleSampleCodeClick = () => {
    setState({ ...state, htmlCode: sampleHtml })
  }

  const handleConvertClick = async () => {
    setState((prevState) => ({ ...prevState, isLoading: true }))
    try {
      const response = await fetch('https://pjdmuj.buildship.run/html-to-pdf', {
        method: 'POST',
        body: JSON.stringify({ html: htmlCode }),
        headers: {
          'Content-Type': 'application/json',
        },
      })
      const data = await response.text()
      setState((prevState) => ({ ...prevState, fetchUrl: data }))
    } catch (error) {
      console.error('Error converting HTML to PDF:', error)
    }
    setState((prevState) => ({ ...prevState, isLoading: false }))
  }

  return (
    <div className="flex h-screen items-center justify-center pt-0">
      <div className="flex w-full flex-col items-center justify-center space-y-1 dark:text-gray-100">
        <h1 className="bg-gradient-to-r from-black to-gray-500 bg-clip-text pb-3 text-center text-3xl font-bold tracking-tighter text-transparent md:text-7xl/none">
          HTML to PDF Converter
        </h1>
        <p className="sm:text-md mx-auto max-w-[650px] pb-1 pt-1 text-gray-600 md:py-3 md:text-xl lg:text-2xl">
          Paste the html code and convert it.
        </p>
        <p
          className="text-md mx-auto cursor-pointer pb-6 text-[#A855F7] underline"
          onClick={handleSampleCodeClick}
        >
          Use sample code
        </p>
        {isLoading ? (
          <div className="flex items-center justify-center">
            <div className="loader"></div>
          </div>
        ) : (
          <div className="flex w-80 flex-col items-center justify-center">
            <textarea
              value={htmlCode}
              onChange={(e) => setState({ ...state, htmlCode: e.target.value })}
              className="mb-4 w-full rounded-lg border border-gray-400 p-2 shadow-sm shadow-black/50"
              placeholder="Paste HTML code here"
              rows={8}
            />
            {fetchUrl ? (
              <div className="mt-4">
                <Link
                  target="_blank"
                  href={fetchUrl}
                  className="w-40 rounded-md bg-black px-8 py-4 text-white transition-all duration-300 hover:bg-black/90"
                  download
                >
                  Download PDF
                </Link>
              </div>
            ) : (
              <div className="mt-4">
                <Button onClick={handleConvertClick}>Convert to PDF</Button>
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  )
}
```

To clear things up.

- state holds an object with properties for isLoading, fetchUrl, and htmlCode.
- setState is used to update the state object.
- Destructuring is used to extract individual state variables.
- Each state update spreads the existing state and only updates the relevant property.

Let's use the route handler now.

Create a new file under `src/app/api/pdftohtml/route.ts`
```typescript
import { NextResponse, NextRequest } from 'next/server'

interface HtmlToPdfRequest {
  html: string
}

export async function POST(req: NextRequest) {
  try {
    // console.log('Request body:', req.body)

    const requestBody = (await req.json()) as HtmlToPdfRequest

    if (!requestBody || !requestBody.html) {
      throw new Error('req body is empty')
    }

    const { html } = requestBody

    // console.log('HTML:', html)

    const response = await fetch('https://pjdmuj.buildship.run/html-to-pdf', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ html }),
    })

    // console.log('Conversion response status:', response.status)

    if (!response.ok) {
      throw new Error('Failed to convert HTML to PDF')
    }

    const pdfUrl = await response.text()
    // console.log('PDF URL:', pdfUrl)

    return NextResponse.json({ url: pdfUrl }) // respond with the PDF URL
  } catch (error) {
    console.error('Error in converting HTML to PDF:', error)
    return NextResponse.json({ error: 'Internal Server Error' })
  }
}
```

I have kept the console statements as comments so you can test things when using it. I used it myself!

![pdf url](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tmxhigsy22p0jcu2we23.png)
<figcaption>we are getting the pdf url correctly</figcaption>

&nbsp;

You can read more about `NextApiRequest`, and `NextApiResponse` on [nextjs docs](https://nextjs.org/docs/pages/building-your-application/routing/api-routes#adding-typescript-types). 

I researched it and found that `NextApiRequest` is the type you use in the pages router API Routes while `NextRequest` is the type you use in the app router Route handlers.

We need to use it in `page.tsx` as follows:
```typescript
...

const handleConvertClick = async () => {
    setState((prevState) => ({ ...prevState, isLoading: true }))
    try {
      const response = await fetch('/api/htmltopdf', {
        method: 'POST',
        body: JSON.stringify({ html: htmlCode }),
        headers: {
          'Content-Type': 'application/json',
        },
      })
      const data = await response.json()

      const pdfUrl = data.url
      // console.log('pdf URL:', pdfUrl)

      setState((prevState) => ({ ...prevState, fetchUrl: pdfUrl }))
    } catch (error) {
      console.error('Error in converting HTML to PDF:', error)
    }
    setState((prevState) => ({ ...prevState, isLoading: false }))
  }

...
```

I also added a cute GitHub SVG effect at the corner which you can check at the deployed link. You can change the position of the SVG easily and clicking it will redirect you to the GitHub Repository :)

- GitHub Repository: [github.com/Anmol-Baranwal/Html2PDF](https://github.com/Anmol-Baranwal/Html2PDF)

- Deployed Link: https://html2-pdf.vercel.app/

---

It may seem simple but you can learn a lot by building simple yet powerful apps.

We can improve this simple use case and build so many cool ideas using the conversion of HTML to PDF.

I was trying BuildShip (open source), and I made this to learn new stuff. There are so many options and integrations which you should definitely explore.

|  If you like this kind of stuff, <br /> please follow me for more :)  |  <a href="https://twitter.com/Anmol_Codes"><img src="https://img.shields.io/badge/Twitter-d5d5d5?style=for-the-badge&logo=x&logoColor=0A0209" alt="profile of Twitter with username Anmol_Codes" ></a> <a href="https://github.com/Anmol-Baranwal"><img src="https://img.shields.io/badge/github-181717?style=for-the-badge&logo=github&logoColor=white" alt="profile of GitHub with username Anmol-Baranwal" ></a> <a href="https://www.linkedin.com/in/Anmol-Baranwal/"><img src="https://img.shields.io/badge/LinkedIn-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white" alt="profile of LinkedIn with username Anmol-Baranwal" /></a> |
|------------|----------|

"Write more, inspire more!"

![Ending GIF waving goodbye](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2ylsck6b9c7ei6makpqd.gif)