
Exporting Reports to PDF and Excel with Next.js
Ask any team that has done user research with enterprise buyers what the most-requested feature in B2B dashboards is, and the answer is almost always the same: "export to Excel." In second place: "generate a PDF for the meeting."
These two formats dominate the enterprise workflow for simple reasons: spreadsheets are the common denominator for business analysis, and PDFs are the standard format for formal reports, presentations, and archiving. A dashboard that can't export forces users to take screenshots or copy numbers manually — and they will, but they'll resent you for it.
React-PDF: PDFs with JSX (Client-side and Server-side)
React-PDF (@react-pdf/renderer) is a library that lets you describe PDFs using JSX components with a CSS-like subset of styles. The result is a programmatically generated PDF file with precise layout, independent of any browser or HTML rendering.
// components/reports/RevenuePDF.tsx
import {
Document, Page, Text, View, StyleSheet, Image
} from '@react-pdf/renderer';
const styles = StyleSheet.create({
page: {
padding: 40,
fontFamily: 'Helvetica',
backgroundColor: '#ffffff',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 24,
paddingBottom: 16,
borderBottom: '1px solid #e5e7eb',
},
title: { fontSize: 20, fontWeight: 'bold', color: '#111827' },
subtitle: { fontSize: 11, color: '#6b7280', marginTop: 4 },
kpiRow: { flexDirection: 'row', gap: 16, marginBottom: 24 },
kpiBox: {
flex: 1,
padding: 16,
backgroundColor: '#f9fafb',
borderRadius: 8,
},
kpiLabel: { fontSize: 10, color: '#6b7280', marginBottom: 4 },
kpiValue: { fontSize: 24, fontWeight: 'bold', color: '#111827' },
table: { marginTop: 16 },
tableRow: { flexDirection: 'row', borderBottom: '1px solid #f3f4f6', padding: '8px 0' },
tableHeader: { fontSize: 10, color: '#6b7280', fontWeight: 'bold' },
tableCell: { fontSize: 10, color: '#374151', flex: 1 },
});
interface RevenueReportData {
period: string;
totalRevenue: number;
growth: number;
topCustomers: Array<{ name: string; revenue: number; orders: number }>;
}
export function RevenuePDF({ data }: { data: RevenueReportData }) {
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.title}>Revenue Report</Text>
<Text style={styles.subtitle}>Period: {data.period}</Text>
</View>
<Text style={{ fontSize: 10, color: '#9ca3af' }}>
Generated on {new Date().toLocaleDateString('en-US')}
</Text>
</View>
<View style={styles.kpiRow}>
<View style={styles.kpiBox}>
<Text style={styles.kpiLabel}>Total Revenue</Text>
<Text style={styles.kpiValue}>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
.format(data.totalRevenue)}
</Text>
</View>
<View style={styles.kpiBox}>
<Text style={styles.kpiLabel}>Growth</Text>
<Text style={{ ...styles.kpiValue, color: data.growth >= 0 ? '#16a34a' : '#dc2626' }}>
{data.growth > 0 ? '+' : ''}{data.growth.toFixed(1)}%
</Text>
</View>
</View>
<Text style={{ fontSize: 13, fontWeight: 'bold', marginBottom: 8 }}>Top Customers</Text>
<View style={styles.table}>
<View style={styles.tableRow}>
<Text style={{ ...styles.tableHeader, flex: 2 }}>Customer</Text>
<Text style={styles.tableHeader}>Revenue</Text>
<Text style={styles.tableHeader}>Orders</Text>
</View>
{data.topCustomers.map((c, i) => (
<View key={i} style={styles.tableRow}>
<Text style={{ ...styles.tableCell, flex: 2 }}>{c.name}</Text>
<Text style={styles.tableCell}>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(c.revenue)}
</Text>
<Text style={styles.tableCell}>{c.orders}</Text>
</View>
))}
</View>
</Page>
</Document>
);
}
// Client-side download:
import { pdf } from '@react-pdf/renderer';
async function downloadPDF(data: RevenueReportData) {
const blob = await pdf(<RevenuePDF data={data} />).toBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `revenue-report-${data.period}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
React-PDF advantages: pixel-perfect layout, no browser dependency, works in Server Actions and API Routes, and the output is consistent across any environment.
Limitations: it doesn't render charts automatically. To include charts in PDFs with React-PDF, you need to export them as SVG or PNG images and embed them as <Image>. This adds complexity to the generation pipeline.
Puppeteer: Page Screenshot as PDF
Puppeteer controls a headless Chrome instance and can generate PDFs from any web page. The approach is different: instead of describing the PDF programmatically, you render the dashboard page in the browser and "print" it as a PDF.
// app/api/reports/pdf/route.ts
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium'; // optimized version for serverless
export async function POST(request: Request) {
const { reportUrl, filename } = await request.json();
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: true,
});
const page = await browser.newPage();
// Inject auth token for protected pages
const sessionToken = request.headers.get('authorization');
await page.setExtraHTTPHeaders({ authorization: sessionToken ?? '' });
await page.goto(`${process.env.NEXT_PUBLIC_URL}${reportUrl}?print=true`, {
waitUntil: 'networkidle0', // wait for all data requests to complete
timeout: 30_000,
});
// Wait for chart animations to finish
await page.waitForTimeout(2000);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' },
});
await browser.close();
return new Response(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}.pdf"`,
}
});
}
Puppeteer advantages: the PDF includes charts and the full dashboard visualization exactly as it appears on screen, with no need to recreate the layout in PDF code. It's faster to implement when the design already exists.
Limitations: requires headless Chrome on the server (~130MB), higher generation latency (2–5s), greater infrastructure cost in serverless environments, and CSS needs adjustments for print mode (@media print).
ExcelJS: Spreadsheets with Full Formatting
For Excel exports, ExcelJS is the most robust library available in Node.js — it supports multiple sheets, cell formatting, formulas, charts, images, and sheet protection.
// lib/exports/excel.ts
import ExcelJS from 'exceljs';
interface ExcelReportOptions {
title: string;
data: Array<Record<string, string | number>>;
columns: Array<{ key: string; header: string; width?: number; format?: string }>;
}
export async function generateExcelReport(options: ExcelReportOptions): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'B2B Dashboard';
workbook.created = new Date();
const sheet = workbook.addWorksheet(options.title, {
pageSetup: { paperSize: 9, orientation: 'landscape' }
});
// Visual header
sheet.mergeCells('A1', `${String.fromCharCode(64 + options.columns.length)}1`);
const titleCell = sheet.getCell('A1');
titleCell.value = options.title;
titleCell.font = { bold: true, size: 14, color: { argb: 'FF111827' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
sheet.getRow(1).height = 32;
// Columns
sheet.columns = options.columns.map(col => ({
key: col.key,
header: col.header,
width: col.width ?? 18,
}));
// Formatted header row
const headerRow = sheet.getRow(2);
headerRow.values = ['', ...options.columns.map(c => c.header)];
headerRow.eachCell(cell => {
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F46E5' } };
cell.alignment = { vertical: 'middle' };
});
headerRow.height = 24;
// Data rows with alternating color
options.data.forEach((row, index) => {
const dataRow = sheet.addRow(row);
if (index % 2 === 0) {
dataRow.eachCell(cell => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF5F3FF' } };
});
}
// Format numeric cells
options.columns.forEach(col => {
if (col.format) {
const cell = dataRow.getCell(col.key);
cell.numFmt = col.format;
}
});
});
// Freeze headers
sheet.views = [{ state: 'frozen', ySplit: 2 }];
// Auto-filter
sheet.autoFilter = {
from: { row: 2, column: 1 },
to: { row: 2, column: options.columns.length }
};
return workbook.xlsx.writeBuffer() as Promise<Buffer>;
}
Client-side vs Server-side Generation
The decision of where to generate the export affects performance, security, and cost:
| Criterion | Client-side | Server-side |
|---|---|---|
| Sensitive data in payload | Risk (transmitted to client) | Safe (stays on server) |
| Large datasets (> 50k rows) | May freeze the browser | Recommended |
| Charts in PDF | Difficult | Easy with Puppeteer |
| Infrastructure cost | None | Server CPU/memory |
| Perceived latency | Immediate (blocks UI) | Async possible |
The rule of thumb: React-PDF on the server for formal report PDFs with sensitive data; ExcelJS on the server for spreadsheets with more than 1,000 rows or financial data; client-side only for simple exports of tables already visible on screen.
Conclusion
Report export is the most underestimated feature in dashboard roadmaps. It sounds simple — "just generate a file" — but a quality implementation involves architectural decisions about where to process, how to handle charts, how to maintain visual consistency with the dashboard, and how to scale when many users export simultaneously.
Implementing export as an afterthought produces ugly PDFs, unformatted spreadsheets, and timeouts on long requests. Implementing it as part of the system design from the start produces exports that users proudly bring to executive meetings.
At SystemForge, export is specified as a requirement in the PRD with format, content, and estimated usage frequency — not added as a last-minute feature before launch.
Need help?


