How to Generate and Email PDF Reports Automatically on a Schedule
Generating a PDF report manually is easy. Doing it automatically — pulling live data, rendering it into a branded HTML template, capturing it as a pixel-perfect PDF, and emailing it to stakeholders — is where most setups fall apart.
The usual blocker: Puppeteer or Playwright in a cron job. Headless browsers in scheduled tasks fail silently, consume memory, and don't survive container restarts.
Here's a robust pipeline: cron → fetch data → render HTML → PageBolt PDF → email.
The stack
npm install node-cron nodemailer
No headless browser dependency. PageBolt handles the PDF rendering.
Basic scheduled report
import cron from "node-cron";
import nodemailer from "nodemailer";
// Runs every Monday at 8am
cron.schedule("0 8 * * 1", async () => {
console.log("Generating weekly report...");
await generateAndSendReport();
});
async function generateAndSendReport() {
// 1. Fetch your data
const data = await fetchReportData();
// 2. Render HTML
const html = renderReportHtml(data);
// 3. Capture as PDF
const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ html }),
});
const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
// 4. Email it
await emailReport(pdfBuffer, data.weekLabel);
}
Fetch data from your database
import { db } from "./db.js"; // your DB client
async function fetchReportData() {
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() - 7);
const [revenue, signups, churn] = await Promise.all([
db.payments.sum({ where: { createdAt: { gte: weekStart } } }),
db.users.count({ where: { createdAt: { gte: weekStart } } }),
db.subscriptions.count({
where: { canceledAt: { gte: weekStart }, status: "canceled" },
}),
]);
const topPages = await db.pageViews.groupBy({
by: ["path"],
_sum: { views: true },
orderBy: { _sum: { views: "desc" } },
take: 5,
where: { createdAt: { gte: weekStart } },
});
return {
weekLabel: `Week of ${weekStart.toLocaleDateString()}`,
revenue: (revenue._sum.amount / 100).toFixed(2),
signups,
churn,
topPages: topPages.map((p) => ({ path: p.path, views: p._sum.views })),
};
}
HTML report template
function renderReportHtml(data) {
const topPagesRows = data.topPages
.map(
(p, i) => `
<tr>
<td>${i + 1}</td>
<td>${p.path}</td>
<td style="text-align:right">${p.views.toLocaleString()}</td>
</tr>`
)
.join("");
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, sans-serif; color: #111; max-width: 720px; margin: 40px auto; padding: 0 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #666; margin-bottom: 32px; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 40px; }
.metric { background: #f5f5f5; border-radius: 8px; padding: 20px; }
.metric-value { font-size: 28px; font-weight: 700; }
.metric-label { color: #666; font-size: 13px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; border-bottom: 2px solid #111; padding: 8px 0; font-size: 13px; }
td { padding: 10px 0; border-bottom: 1px solid #eee; font-size: 14px; }
h2 { font-size: 16px; margin: 32px 0 12px; }
</style>
</head>
<body>
<h1>Weekly Report</h1>
<div class="subtitle">${data.weekLabel}</div>
<div class="metrics">
<div class="metric">
<div class="metric-value">$${data.revenue}</div>
<div class="metric-label">Revenue</div>
</div>
<div class="metric">
<div class="metric-value">${data.signups}</div>
<div class="metric-label">New signups</div>
</div>
<div class="metric">
<div class="metric-value">${data.churn}</div>
<div class="metric-label">Cancellations</div>
</div>
</div>
<h2>Top pages</h2>
<table>
<thead>
<tr><th>#</th><th>Page</th><th style="text-align:right">Views</th></tr>
</thead>
<tbody>${topPagesRows}</tbody>
</table>
</body>
</html>`;
}
Email the PDF
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
async function emailReport(pdfBuffer, weekLabel) {
const recipients = process.env.REPORT_RECIPIENTS.split(",");
await transporter.sendMail({
from: "reports@yourapp.com",
to: recipients,
subject: `Weekly Report — ${weekLabel}`,
text: "Your weekly report is attached.",
attachments: [
{
filename: `weekly-report-${Date.now()}.pdf`,
content: pdfBuffer,
contentType: "application/pdf",
},
],
});
console.log(`Report sent to ${recipients.join(", ")}`);
}
Per-customer scheduled reports
Send a personalized report to each user on their billing cycle:
// Called by a daily cron at midnight
cron.schedule("0 0 * * *", async () => {
const today = new Date().getDate();
// Find users whose billing day matches today
const users = await db.users.findMany({
where: { billingDay: today, reportEnabled: true },
});
await Promise.allSettled(
users.map(async (user) => {
const data = await fetchUserReportData(user.id);
const html = renderUserReportHtml(user, data);
const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ html }),
});
const pdf = Buffer.from(await pdfRes.arrayBuffer());
await emailReport(pdf, user.email, `Your monthly summary`);
})
);
});
Promise.allSettled ensures one failure doesn't abort the batch — each user's report is independent.
Deploy on a VPS or Lambda
VPS / PM2:
pm2 start report-scheduler.js --name weekly-reports
Lambda (EventBridge schedule):
// handler.js — triggered by EventBridge rule: cron(0 8 ? * MON *)
export const handler = async () => {
await generateAndSendReport();
return { statusCode: 200 };
};
No browser binary in the Lambda package. The PDF generation is a single outbound HTTP request — stays well within Lambda's memory and timeout limits.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)