Generating PDF reports from database data is one of the most common tasks in business applications. Whether you are building a dashboard that exports monthly analytics, a CRM that produces client summaries, or an ERP system that prints inventory reports, the workflow is fundamentally the same: query your database, transform the results into HTML, and render that HTML as a PDF.
In this tutorial, we will walk through the complete process using Node.js, PostgreSQL, and TongoRender's HTML-to-PDF API.
The Architecture
Before diving into code, let us outline the data flow:
- Query — Connect to your database and fetch the data you need for the report.
- Transform — Map the raw query results into an HTML template with tables, charts, and summary statistics.
- Render — Send the HTML to an API that converts it into a PDF.
- Deliver — Save the PDF to disk, upload it to cloud storage, or email it to a recipient.
Step 1: Setting Up the Database Connection
We will use the pg library to connect to PostgreSQL. Install the dependencies:
npm install pg dotenv node-fetch
Create a database helper:
// db.js
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
async function query(text, params) {
const result = await pool.query(text, params);
return result.rows;
}
module.exports = { query };
Step 2: Querying Report Data
Suppose we want a monthly sales report. We need total revenue, order count, top products, and a daily breakdown:
// reports/monthly-sales.js
const { query } = require('../db');
async function getMonthlySalesData(year, month) {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0);
const [summary] = await query(
`SELECT COUNT(*) as order_count,
SUM(total) as revenue,
AVG(total) as avg_order_value
FROM orders
WHERE created_at BETWEEN $1 AND $2`,
[startDate, endDate]
);
const dailyBreakdown = await query(
`SELECT DATE(created_at) as date,
COUNT(*) as orders,
SUM(total) as revenue
FROM orders
WHERE created_at BETWEEN $1 AND $2
GROUP BY DATE(created_at)
ORDER BY date`,
[startDate, endDate]
);
const topProducts = await query(
`SELECT p.name, SUM(oi.quantity) as units_sold, SUM(oi.subtotal) as revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN orders o ON o.id = oi.order_id
WHERE o.created_at BETWEEN $1 AND $2
GROUP BY p.name
ORDER BY revenue DESC
LIMIT 10`,
[startDate, endDate]
);
return { summary, dailyBreakdown, topProducts };
}
Step 3: Building the HTML Template
Now we transform the data into a well-structured HTML document with print-friendly CSS:
function buildReportHTML(data, year, month) {
const monthName = new Date(year, month - 1).toLocaleString('en', { month: 'long' });
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Helvetica Neue', sans-serif; color: #1a1a2e; margin: 40px; }
h1 { color: #16213e; border-bottom: 3px solid #0f3460; padding-bottom: 10px; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 20px 0; }
.summary-card { background: #f0f4ff; border-radius: 8px; padding: 20px; text-align: center; }
.summary-card .value { font-size: 2em; font-weight: 700; color: #0f3460; }
.summary-card .label { color: #666; margin-top: 5px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th { background: #0f3460; color: white; padding: 10px; text-align: left; }
td { padding: 10px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #f8f9fa; }
@media print { .summary-card { break-inside: avoid; } }
</style>
</head>
<body>
<h1>Monthly Sales Report — ${monthName} ${year}</h1>
<div class="summary-grid">
<div class="summary-card">
<div class="value">${data.summary.order_count}</div>
<div class="label">Total Orders</div>
</div>
<div class="summary-card">
<div class="value">${Number(data.summary.revenue).toLocaleString()}</div>
<div class="label">Revenue</div>
</div>
<div class="summary-card">
<div class="value">${Number(data.summary.avg_order_value).toFixed(2)}</div>
<div class="label">Average Order Value</div>
</div>
</div>
<h2>Top Products</h2>
<table>
<thead><tr><th>Product</th><th>Units Sold</th><th>Revenue</th></tr></thead>
<tbody>
${data.topProducts.map(p => \`
<tr><td>${p.name}</td><td>${p.units_sold}</td><td>${Number(p.revenue).toLocaleString()}</td></tr>
\`).join('')}
</tbody>
</table>
</body>
</html>`;
}
Step 4: Converting to PDF with TongoRender
With the HTML ready, a single API call produces the PDF:
const fetch = require('node-fetch');
async function generateReport(year, month) {
const data = await getMonthlySalesData(year, month);
const html = buildReportHTML(data, year, month);
const response = await fetch('https://api.tongorender.io/v1/pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.TONGORENDER_API_KEY,
},
body: JSON.stringify({
html,
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
displayHeaderFooter: true,
footerTemplate: '<div style="font-size:10px;text-align:center;width:100%">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
}),
});
if (!response.ok) throw new Error(`API error: ${response.statusText}`);
return Buffer.from(await response.arrayBuffer());
}
// Generate and save
const pdf = await generateReport(2026, 3);
fs.writeFileSync('reports/sales-march-2026.pdf', pdf);
Scheduling Reports with Cron
Automate report generation by scheduling a cron job that runs on the first of each month:
const cron = require('node-cron');
cron.schedule('0 8 1 * *', async () => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth(); // Previous month
const pdf = await generateReport(year, month);
await uploadToS3(pdf, `reports/${year}-${String(month).padStart(2, '0')}.pdf`);
await sendEmail('finance@company.com', 'Monthly Sales Report', pdf);
});
Tips for Production-Quality Reports
- Handle empty data gracefully — Show "No data available" messages instead of empty tables.
- Format numbers consistently — Use
Intl.NumberFormatfor currency and large numbers. - Add timestamps — Include the generation date and time range in the report header.
- Use connection pooling — The
pg.Poolmanages connections efficiently for concurrent requests. - Cache templates — If the HTML template does not change, compile it once and reuse for different data.
TongoRender makes the PDF rendering step effortless. Focus on querying the right data and designing a clear template — the API handles the rest.
Start generating reports with TongoRender — 100 free renders per month, no credit card required.