How to build an HTML to PDF app in 5 minutes

How to build an HTML to PDF app in 5 minutes

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:

Making API in BuildShip.
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!

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.

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.

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.

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

HTML Content

 

Options

 

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.

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

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

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.

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

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

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

The 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()

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.

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.

using sample code

 

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

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

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

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

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

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,
please follow me for more 🙂

“Write more, inspire more!”