One of the most common tasks in web applications is generating PDF documents: invoices, reports, tickets, receipts, and more.
Doing this from scratch can be tedious, but with HTML + CSS and a templating engine like Handlebars, the task becomes much easier. And if we add PDF Echo, we can seamlessly transform those HTML documents into ready-to-use PDFs for your users.
In this post, I’ll show you how to build a small project with Node.js + Express + Handlebars to generate an Invoice and convert it to PDF with PDF Echo.
1. Create the Node.js project
First, initialize a new project:
mkdir invoice-pdf
cd invoice-pdf
npm init -y
npm install -E express express-handlebars
2. Configuring package.json
Before writing any code, let’s update your package.json to make sure Node.js runs in ESM (ECMAScript Modules) mode and that you have handy scripts for development and production.
In your package.json
, add:
{
"type": "module",
"scripts": {
"start": "node --env-file=.env src/index.js",
"dev": "node --watch --env-file=.env src/index.js"
}
}
-
"type"
:"module"
allows us to use modernimport
/export
syntax. - The
"start"
script runs the app normally. - The
"dev"
script runs the app in watch mode so it reloads automatically when you make changes.
3. Configure Express and Handlebars
In src/app.js
:
import express from 'express'
import { engine } from 'express-handlebars'
import path from 'node:path'
const app = express()
app.engine('hbs', engine({ defaultLayout: false }))
app.set('view engine', 'hbs')
app.set('views', path.join('src', 'views'))
export { app }
In src/index.js
import { app } from './app.js'
const PORT = process.env.PORT ?? 4000
app.listen(PORT, '0.0.0.0', (error) => {
if (error !== undefined) {
console.log(error)
return
}
console.log(`SV ON PORT: ${PORT}`)
})
4. Create an Invoice template with Handlebars
Now let’s design the invoice template.
Create a file at src/views/invoice.hbs
and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice</title>
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif
}
.wave-top {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 12rem;
background: linear-gradient(to right, #F69474, #ED2762);
clip-path: ellipse(65% 100% at 0% 0%);
}
main {
position: relative;
padding: 2rem 4rem;
header {
display: flex;
justify-content: space-between;
margin-bottom: 4rem;
.hero {
h1 {
font-size: 3rem;
text-transform: uppercase;
margin-bottom: 0.5rem;
color: #FFFFFF;
}
h2 {
font-weight: 400;
font-size: 1rem;
span {
font-weight: 600;
text-transform: uppercase;
}
}
}
.company {
span {
font-size: 2rem;
margin-bottom: 1rem;
display: block;
}
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
li {
font-size: 0.75rem
}
}
}
}
.overview {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
.box {
background: rgba(0, 0, 0, 0.1);
width: 100%;
height: 150px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
h4 {
font-size: 1rem;
}
span {
font-size: 0.75rem;
}
}
}
table {
border-spacing: 0;
width: 100%;
thead {
background: linear-gradient(to right, #EE2762, #F89675);
tr {
th {
padding: 1rem 2rem;
text-transform: uppercase;
color: #FFFFFF;
text-align: left;
&:first-child {
border-top-left-radius: 999px;
border-bottom-left-radius: 999px;
}
&:last-child {
border-top-right-radius: 999px;
border-bottom-right-radius: 999px;
}
}
}
}
tbody {
tr {
td {
padding: 1rem 2rem;
font-weight: 600;
}
}
}
}
hr {
margin: 1rem 0;
}
.summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1rem;
.row {
display: flex;
align-items: center;
justify-content: space-between;
width: 40%;
min-width: 2rem;
h3 {
text-align: right;
min-width: 10rem;
width: 50%;
font-weight: 600;
}
span {
font-weight: 600;
&.fill {
background: linear-gradient(to right, #EE2762, #F89675);
padding: 0.5rem;
color: #FFFFFF;
}
}
}
}
}
</style>
</head>
<body>
<div class="wave-top"></div>
<main>
<header>
<div class="hero">
<h1>Invoice</h1>
<h2>Invoice to: <span>{{customer_name}}</span></h2>
</div>
<div class="company">
<span>LOGO</span>
<ul>
<li>Address: Your Street No, 223 NY USA</li>
<li>Phone: <a href="tel:+1234567890">+123 456 7890</a></li>
<li>Email: <a href="mailto:your@email.com">your@email.com</a></li>
<li>Website: <a href="https://example.com">example.com</a></li>
</ul>
</div>
</header>
<div class="overview">
<div class="box">
<h4>Date</h4>
<span>{{created_at}}</span>
</div>
<div class="box">
<h4>Service NO.</h4>
<span>{{service_no}}</span>
</div>
<div class="box">
<h4>Account NO.</h4>
<span>{{account_no}}</span>
</div>
<div class="box">
<h4>Amount</h4>
<span>${{sum total_amount (calculateTax tax total_amount)}}</span>
</div>
</div>
<table>
<thead>
<tr>
<th>Item Description</th>
<th>Price</th>
<th>QTY</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{name}}</td>
<td>${{unit_price}}</td>
<td>{{quantity}}</td>
<td>${{multiply unit_price quantity}}</td>
</tr>
{{/each}}
</tbody>
</table>
<hr />
<div class="summary">
<div class="row">
<h3>Subtotal</h3>
<span>${{total_amount}}</span>
</div>
<div class="row">
<h3>VAT/TAX {{tax}}%</h3>
<span>${{calculateTax tax total_amount}}</span>
</div>
<div class="row">
<h3>Total Due</h3>
<span class="fill">${{sum total_amount (calculateTax tax total_amount)}}</span>
</div>
</div>
</main>
</body>
</html>
Notice this template uses custom helpers:
-
multiply
→ to calculate line totals -
calculateTax
→ to apply tax percentage -
sum
→ to add subtotal + tax
Before we can render this template, we need to register these helpers in our Express app.
In src/app.js
:
app.engine('hbs', engine({
defaultLayout: false,
helpers: {
sum: (a, b) => a + b,
multiply: (a, b) => a * b,
calculateTax: (tax, totalAmount) => Math.round(totalAmount * (tax / 100))
}
}))
Now Handlebars knows how to handle those operations, and the template will render correctly.
4. Render the template in a route
Before integrating with PDF Echo, let’s make sure that our Handlebars template is rendering correctly in the browser. This way, we can quickly spot any issues with the template or helpers before moving on.
Create a simple route in your Express server that compiles the template with some mock data and returns the rendered HTML:
app.get('/invoice', (req, res) => {
const MOCKUP_DATA = {
id: 'inv_cmeyqpbks000008iccyi9fd20',
customer_name: 'Joe Doe',
account_no: 'CUST-12312332',
service_no: 'SRV-123123213',
items: [
{ name: 'Graphic Design', unit_price: 125, quantity: 2 },
{ name: 'Web Design', unit_price: 150, quantity: 1 },
{ name: 'Branding Design', unit_price: 50, quantity: 1 },
{ name: 'Brochure Design', unit_price: 50, quantity: 1 }
],
created_at: '30-08-2025',
tax: 7.5
}
const totalAmount = MOCKUP_DATA.items.reduce((acc, value) => acc + value.unit_price * value.quantity, 0)
res.render('invoice', {
...MOCKUP_DATA,
total_amount: totalAmount
})
})
And visit http://localhost:4000/invoice in your browser.
You should see the rendered invoice with your mock data 🎉.
👉 This step is optional but very useful. It helps you confirm that:
- Your Handlebars template compiles without errors.
- Your custom helpers (from Step 3) are working correctly.
- The HTML layout looks good before exporting it to PDF.
Once everything looks fine in the browser, we’ll move on to connecting with PDF Echo to generate a real PDF.
5. Integrate with PDF Echo to Generate a PDF
Now that we’ve verified that our template renders correctly in the browser, it’s time to generate a real PDF using PDF Echo.
PDF Echo allows you to send HTML and receive a fully rendered PDF — no need to worry about Puppeteer, wkhtmltopdf, or other heavy engines. To use the API, you'll need an API key. You can get yours for free by signing up on our website and navigating to your dashboard. For a detailed guide, please refer to our Quickstart Documentation.
Create a new route that sends the rendered HTML to PDF Echo and returns the PDF to the client:
app.get('/invoice/pdf', (req, res) => {
const MOCKUP_DATA = {
id: 'inv_cmeyqpbks000008iccyi9fd20',
customer_name: 'Joe Doe',
account_no: 'CUST-12312332',
service_no: 'SRV-123123213',
items: [
{ name: 'Graphic Design', unit_price: 125, quantity: 2 },
{ name: 'Web Design', unit_price: 150, quantity: 1 },
{ name: 'Branding Design', unit_price: 50, quantity: 1 },
{ name: 'Brochure Design', unit_price: 50, quantity: 1 }
],
created_at: '30-08-2025',
tax: 7.5
}
const totalAmount = MOCKUP_DATA.items.reduce((acc, value) => acc + value.unit_price * value.quantity, 0)
res.render(
'invoice',
{
...MOCKUP_DATA,
total_amount: totalAmount
},
async (error, html) => {
if (error !== undefined) {
res.status(400).json({ error: { code: 'invalid_html', type: 'invalid_request_error' } })
}
try {
const request = await fetch('https://api.pdfecho.com', {
method: 'POST',
body: JSON.stringify({
source: html
}),
headers: {
Authorization: `Basic ${Buffer.from('sk_test_****' + ':').toString('base64')}`,
'pe-test-mode': 'true'
}
})
const data = await request.arrayBuffer()
res
.contentType('Content-Type', 'application/pdf')
.send(Buffer.from(data))
} catch (error) {
res.status(500).json({ error: { code: 'server_internal_error', type: 'api_error' } })
}
}
)
})
Let's break down the most important parts of this code:
res.render()
Callback: Instead of sending the rendered HTML directly, we're using a three-argument version of res.render()
. The third argument is a callback function that executes once the template is fully rendered. This function receives two parameters: error
and html
. The html parameter is a string containing our final, rendered HTML, which is exactly what we need for the next step.
fetch()
to the PDF Echo API:
method: 'POST'
: We send the request using a POST method because we are sending data (the HTML) to the API.body: JSON.stringify({ source: html })
: The API expects a JSON object in the request body. This object must have a key calledsource
, and its value is thehtml
string we got from theres.render
callback.Authorization
header: This header is essential for authenticating your request with your API Key. We use basic authentication, encoding our API key in Base64.'pe-test-mode': 'true'
: This header tells the API to use its test mode, which is great for development and testing without using up your production credits.
Then open http://localhost:4000/invoice/pdf in your browser.
You should see the PDF download automatically with your rendered invoice. 🎉
Repository: https://github.com/pdf-echo/example-invoice-nodejs-express-hbs
In this tutorial, we set up an Express server and integrated it with PDF Echo to generate PDF documents in real time.
From here, you can:
Explore the fields of the
/v1/pdf
endpoint to see what other options you have when generating the PDF.Configure a webhook to automatically receive the PDF without needing to poll.
Store PDFs in services like AWS S3 or Google Cloud Storage, and work directly with the public or signed URL.
Extend your templates to practical use cases such as invoices, purchase orders, reports, or contracts — fully automated.
With this infrastructure, you now have a flexible backend that can integrate into any app or business workflow.
Top comments (0)