How to build an HTML to PDF app in 5 minutes

Anmol Baranwal - May 25 - - Dev Community

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 and go to the dashboard. This is how it looks!

dashboard

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

rest api trigger

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

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

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

html content

HTML Content

 

Options

Options

 

file path

We will keep the path file as UUID so it's unique

 

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

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

logs

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

final testing

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

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


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, 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.

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

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

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

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

The output.

output

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.

  const [isLoading, setIsLoading] = useState(false)
  const [fetchUrl, setFetchUrl] = useState('')
  const [htmlCode, setHtmlCode] = useState('')
Enter fullscreen mode Exit fullscreen mode

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

The API request is sent when the button is clicked.

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

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

sample code

using sample code

 

final pdf

final pdf

 

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.

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

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

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

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

pdf url

we are getting the pdf url correctly

 

You can read more about NextApiRequest, and NextApiResponse on nextjs docs.

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:

...

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 }))
  }

...
Enter fullscreen mode Exit fullscreen mode

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 :)


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,
please follow me for more :)
profile of Twitter with username Anmol_Codes profile of GitHub with username Anmol-Baranwal profile of LinkedIn with username Anmol-Baranwal

"Write more, inspire more!"

Ending GIF waving goodbye

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .