Generating PDFs, Excel, and PowerPoint Reports in Next.js
Quick Answer
Generate multi-format reports in Next.js by using jsPDF + jsPDF-autotable for PDFs (with QuickChart.io for charts), XLSX for Excel exports with multiple sheets, and PptxGenJS for PowerPoint presentations. Handle downloads client-side with Blob URLs, track exports in audit logs, and cache data with Redis for performance.
The Challenge of Multi-Format Data Exports
Users expect data in their preferred format. Finance teams want Excel for analysis. Executives need PowerPoint for presentations. Legal requires PDFs for documentation. Building a single export system that handles all formats gracefully is challenging.
In a recent client project, we implemented a comprehensive export system supporting PDF, Excel, PowerPoint, and CSV formats. This article shares how we built it, the libraries we chose, and the patterns that made it maintainable.
Real-World Requirements
Our application needed to export:
- Analytics reports - Charts, tables, and metrics for team meetings
- Request forms - Formatted PDFs with reference numbers
- User data - Excel workbooks with multiple sheets for compliance reports
- Board presentations - PowerPoint decks with charts and branded slides
All exports needed audit logging, download tracking, and proper file naming conventions.
Choosing the Right Libraries
PDF Generation: jsPDF + jsPDF-autotable
Why jsPDF:
- Client-side generation (no server load)
- Excellent table support with autotable plugin
- Image embedding for logos and charts
- Wide browser compatibility
Installation:
npm install jspdf jspdf-autotable
Alternatives considered:
- react-pdf - Better React integration, but heavier bundle
- pdfmake - Declarative API, but less flexible for complex layouts
- Puppeteer - Server-side HTML to PDF, but resource-intensive
Excel Generation: XLSX
Why XLSX:
- Pure JavaScript (works client and server-side)
- Multiple sheets support
- Cell formatting and styling
- Formula support
Installation:
npm install xlsx
Alternatives considered:
- exceljs - More features, but larger bundle size
- csv-writer - Simpler, but no multi-sheet support
PowerPoint Generation: PptxGenJS
Why PptxGenJS:
- Native PowerPoint file generation
- Chart integration via URLs
- Table and text formatting
- Slide layouts and templates
Installation:
npm install pptxgenjs
Alternatives considered:
- officegen - Older, less maintained
- node-pptx - Limited features
Server-Side Charts: QuickChart.io
For charts in PDFs and PowerPoint, we use QuickChart.io's API:
Why QuickChart.io:
- Chart.js compatible API
- No server-side rendering needed
- Returns PNG/SVG images via URL
- Free tier available
PDF Generation with Charts
Basic PDF Structure
Here's how we generate analytics PDFs with tables and charts:
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
async function generateAnalyticsPDF(
section: string,
data: any,
dateRange: { from: string; to: string }
): Promise<Buffer> {
const doc = new jsPDF()
// Header with branding
doc.setFontSize(20)
doc.setTextColor(31, 67, 157)
doc.text('Analytics Report', 14, 20)
doc.setFontSize(12)
doc.setTextColor(0, 0, 0)
doc.text(`Section: ${section.toUpperCase()}`, 14, 30)
doc.text(`Period: ${formatDate(dateRange.from)} - ${formatDate(dateRange.to)}`, 14, 37)
// Overview statistics table
autoTable(doc, {
startY: 55,
head: [['Metric', 'Value']],
body: [
['Total Users', data.totalUsers?.toString() || '0'],
['Active', data.active?.toString() || '0'],
['Pending', data.pending?.toString() || '0']
],
theme: 'grid',
headStyles: { fillColor: [31, 67, 157], fontStyle: 'bold' }
})
// Add chart if we have trend data (see generateLineChart below)
// ...chart embedding code...
return Buffer.from(doc.output('arraybuffer'))
}
Generating Charts with QuickChart.io
The key to beautiful PDFs is server-rendered charts:
async function generateLineChart(
labels: string[],
datasets: Array<{ label: string; data: number[]; color: string }>
): Promise<Buffer> {
const config = {
type: 'line',
data: {
labels,
datasets: datasets.map(ds => ({
label: ds.label,
data: ds.data,
borderColor: ds.color,
backgroundColor: ds.color + '20',
tension: 0.4,
fill: true
}))
},
options: { responsive: true, scales: { y: { beginAtZero: true } } }
}
const response = await fetch('https://quickchart.io/chart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chart: config, width: 600, height: 400 })
})
return Buffer.from(await response.arrayBuffer())
}
Handling Multi-Page PDFs
Manage page breaks intelligently:
function addContentWithPageBreak(
doc: jsPDF,
content: string,
yPosition: number,
maxY: number = 250
): number {
if (yPosition > maxY) {
doc.addPage()
return 20 // Reset to top of new page
}
doc.text(content, 14, yPosition)
return yPosition + 10
}
Excel Generation with Formatting
Creating Multi-Sheet Workbooks
Here's how we generate Excel exports with multiple sheets:
import * as XLSX from 'xlsx'
function generateAnalyticsExcel(
section: string,
data: any,
dateRange: { from: string; to: string }
): Buffer {
const workbook = XLSX.utils.book_new()
// Overview sheet with metadata
const overviewData = [
['Analytics Report'],
['Section:', section],
['Period:', `${formatDate(dateRange.from)} - ${formatDate(dateRange.to)}`],
[],
['Metric', 'Value'],
['Total Users', data.totalUsers || 0],
['Active', data.active || 0],
['Pending', data.pending || 0]
]
const overviewSheet = XLSX.utils.aoa_to_sheet(overviewData)
XLSX.utils.book_append_sheet(workbook, overviewSheet, 'Overview')
// Trend data sheet
if (data.userTrend?.length > 0) {
const trendSheet = XLSX.utils.aoa_to_sheet([
['Date', 'Users', 'Active'],
...data.userTrend.map(t => [t.date, t.users || 0, t.active || 0])
])
XLSX.utils.book_append_sheet(workbook, trendSheet, 'Trend')
}
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }))
}
Adding Cell Formatting
Apply styling to Excel cells:
function createFormattedSheet(data: any[][]): XLSX.WorkSheet {
const sheet = XLSX.utils.aoa_to_sheet(data)
const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1')
// Style header row
for (let col = range.s.c; col <= range.e.c; col++) {
const cell = sheet[XLSX.utils.encode_cell({ r: 0, c: col })]
if (cell) cell.s = { font: { bold: true }, fill: { fgColor: { rgb: '1F439D' } } }
}
return sheet
}
Exporting Large Datasets
For large datasets, consider streaming:
function generateLargeExcel(data: any[]): Readable {
const workbook = XLSX.utils.book_new()
const CHUNK_SIZE = 1000
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
const sheet = XLSX.utils.json_to_sheet(data.slice(i, i + CHUNK_SIZE))
XLSX.utils.book_append_sheet(workbook, sheet, `Sheet ${Math.floor(i / CHUNK_SIZE) + 1}`)
}
return Readable.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }))
}
PowerPoint Generation
Creating Presentation Slides
Here's how we generate PowerPoint presentations:
import PptxGenJS from 'pptxgenjs'
async function generateAnalyticsPowerPoint(
section: string,
data: any,
dateRange: { from: Date; to: Date }
): Promise<Buffer> {
const pptx = new PptxGenJS()
pptx.title = `Analytics Report - ${section}`
const brandColor = '1F439D'
// Title slide
const titleSlide = pptx.addSlide()
titleSlide.addText('Analytics Report', {
x: 0.5, y: 1.5, w: 9, h: 1,
fontSize: 44, bold: true, color: brandColor, align: 'center'
})
titleSlide.addText(`Section: ${section.toUpperCase()}`, {
x: 0.5, y: 3, w: 9, h: 0.5, fontSize: 24, color: '666666', align: 'center'
})
// Overview slide with table
const overviewSlide = pptx.addSlide()
overviewSlide.addText('Overview', {
x: 0.5, y: 0.3, w: 9, h: 0.5, fontSize: 32, bold: true, color: brandColor
})
overviewSlide.addTable([
['Metric', 'Value'],
['Total Users', data.totalUsers?.toString() || '0'],
['Active', data.active?.toString() || '0'],
['Pending', data.pending?.toString() || '0']
], { x: 1, y: 1.5, w: 8, colW: [3, 2], fontSize: 14 })
// Chart slide using QuickChart.io URL
if (data.userTrend?.length > 0) {
const chartConfig = { type: 'line', data: { labels: data.userTrend.map(t => t.date), datasets: [{ label: 'Users', data: data.userTrend.map(t => t.users) }] } }
const chartUrl = `https://quickchart.io/chart?c=${encodeURIComponent(JSON.stringify(chartConfig))}&width=800&height=400`
const trendSlide = pptx.addSlide()
trendSlide.addImage({ path: chartUrl, x: 1, y: 1.5, w: 8, h: 4 })
}
return (await pptx.write({ outputType: 'nodebuffer' })) as Buffer
}
Adding Multiple Charts
Create slides with multiple visualizations:
async function addComparisonSlide(pptx: PptxGenJS, data: any, brandColor: string) {
const slide = pptx.addSlide()
slide.addText('Category Comparison', {
x: 0.5, y: 0.3, w: 9, h: 0.5, fontSize: 28, bold: true, color: brandColor
})
// Pie chart (left) and bar chart (right)
const pieUrl = await generatePieChartUrl(data.categories.map(c => c.name), data.categories.map(c => c.count))
const barUrl = await generateBarChartUrl(data.segments.map(s => s.label), data.segments.map(s => s.value))
slide.addImage({ path: pieUrl, x: 0.5, y: 1.5, w: 4, h: 3 })
slide.addImage({ path: barUrl, x: 5, y: 1.5, w: 4, h: 3 })
}
Unified Export API Endpoint
Create a single endpoint handling all formats:
// app/api/analytics/export/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const section = searchParams.get('section')
const format = searchParams.get('format') as 'pdf' | 'excel' | 'csv' | 'powerpoint'
const from = searchParams.get('from')
const to = searchParams.get('to')
if (!section || !format || !from || !to) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 })
}
const data = await fetchAnalyticsData(section, from, to)
const generators = {
pdf: { fn: generatePDF, type: 'application/pdf', ext: 'pdf' },
excel: { fn: generateExcel, type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ext: 'xlsx' },
csv: { fn: generateCSV, type: 'text/csv', ext: 'csv' },
powerpoint: { fn: generatePowerPoint, type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ext: 'pptx' }
}
const { fn, type, ext } = generators[format]
const buffer = await fn(section, data, from, to)
await logExport(req.user?.id, section, format)
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': type,
'Content-Disposition': `attachment; filename="analytics-${section}.${ext}"`
}
})
}
Client-Side Download Handling
Trigger downloads from the browser:
'use client'
import { useState } from 'react'
type ExportFormat = 'pdf' | 'excel' | 'csv' | 'powerpoint'
export function ExportControls({ section, dateRange }: ExportControlsProps) {
const [isExporting, setIsExporting] = useState(false)
const handleExport = async (format: ExportFormat) => {
setIsExporting(true)
try {
const url = `/api/analytics/export?section=${section}&format=${format}&from=${dateRange.from.toISOString()}&to=${dateRange.to.toISOString()}`
const response = await fetch(url)
if (!response.ok) throw new Error(`Export failed: ${response.status}`)
const blob = await response.blob()
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = `analytics-${section}.${format === 'powerpoint' ? 'pptx' : format === 'excel' ? 'xlsx' : format}`
link.click()
window.URL.revokeObjectURL(link.href)
} catch (error) {
console.error('Export error:', error)
} finally {
setIsExporting(false)
}
}
return (
<div className="flex gap-2">
{['pdf', 'excel', 'csv', 'powerpoint'].map((format) => (
<button key={format} onClick={() => handleExport(format as ExportFormat)} disabled={isExporting}>
Export {format.toUpperCase()}
</button>
))}
</div>
)
}
File Naming Conventions
Use consistent, descriptive filenames:
function generateExportFilename(section: string, format: string, referenceNumber?: string): string {
const timestamp = formatDate(new Date(), 'yyyy-MM-dd')
if (referenceNumber) return `${referenceNumber}-${section}-${timestamp}.${format}`
return `analytics-${section}-${timestamp}.${format}`
}
// Examples: "REQ-2025-00042-request-2025-04-13.pdf", "analytics-users-2025-04-13.xlsx"
Download Tracking and Audit Logging
Track all exports for compliance:
import { db, auditLogs } from '@/lib/database'
async function logExport(userId: string | null, section: string, format: string) {
await db.insert(auditLogs).values({
userId,
actionType: 'CREATE',
targetTable: 'analytics_export',
targetDescription: `Analytics export: ${section} (${format.toUpperCase()})`,
metadata: { section, exportFormat: format },
createdAt: new Date()
})
}
async function getExportHistory(userId: string, limit = 20) {
return db.select().from(auditLogs)
.where(and(eq(auditLogs.userId, userId), eq(auditLogs.targetTable, 'analytics_export')))
.orderBy(desc(auditLogs.createdAt)).limit(limit)
}
Performance Optimization
Caching Export Data
Use Redis to cache analytics data:
import { redis } from '@/lib/redis'
async function getCachedOrFetch<T>(cacheKey: string, fetchFn: () => Promise<T>, ttl = 300): Promise<T> {
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const data = await fetchFn()
await redis.setex(cacheKey, ttl, JSON.stringify(data))
return data
}
// Usage: cache analytics data for 5 minutes
const data = await getCachedOrFetch(`analytics:${section}:${from}:${to}`, () => fetchAnalyticsData(section, from, to))
Streaming Large Files
For very large exports, use streaming:
export async function GET(req: NextRequest) {
const stream = generateLargeExcelStream(data)
return new Response(stream, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="large-export.xlsx"'
}
})
}
Lazy Chart Generation
Only generate charts when needed:
async function generatePDFWithLazyCharts(data: any) {
const doc = new jsPDF()
addStaticContent(doc, data)
// Generate charts in parallel
const charts = await Promise.all(
data.sections.filter(s => s.hasChart).map(s => generateChart(s.chartData))
)
charts.forEach((chart, i) => addChartToPage(doc, chart, i))
return doc.output('arraybuffer')
}
Styling and Branding
Consistent Color Palette
Define brand colors once:
// lib/exports/theme.ts
export const ExportTheme = {
colors: { primary: '1F439D', secondary: 'C41C32', accent: 'FED017', text: '363636' },
fonts: { title: 44, heading: 32, body: 14, caption: 10 },
spacing: { small: 0.5, medium: 1, large: 1.5 }
}
Reusable Components
Create helpers for consistent styling:
function addBrandedHeader(doc: jsPDF, title: string, subtitle: string): number {
doc.setFontSize(ExportTheme.fonts.title)
doc.setTextColor(...hexToRgb(ExportTheme.colors.primary))
doc.text(title, 14, 20)
doc.setFontSize(ExportTheme.fonts.body)
doc.setTextColor(...hexToRgb(ExportTheme.colors.text))
doc.text(subtitle, 14, 30)
return 40
}
const hexToRgb = (hex: string): [number, number, number] => [
parseInt(hex.substring(0, 2), 16),
parseInt(hex.substring(2, 4), 16),
parseInt(hex.substring(4, 6), 16)
]
Error Handling
Graceful Degradation
Handle missing data gracefully:
function generateReportSection(doc: jsPDF, data: any, yPosition: number) {
if (!data || Object.keys(data).length === 0) {
doc.setFontSize(12)
doc.text('No data available for this section.', 14, yPosition)
return yPosition + 10
}
return renderData(doc, data, yPosition)
}
Export Validation
Validate data before generating exports:
function validateExportData(section: string, data: any): void {
if (!data) throw new Error('Export data is empty')
const requiredFields: Record<string, string[]> = {
users: ['totalUsers', 'active'],
analytics: ['overview', 'trends'],
reports: ['summary']
}
const required = requiredFields[section]
if (!required) throw new Error(`Unknown section: ${section}`)
for (const field of required) {
if (!(field in data)) throw new Error(`Missing required field: ${field}`)
}
}
Testing Export Generation
Unit Tests
Test individual generators:
import { describe, it, expect } from 'vitest'
describe('PDF Generation', () => {
it('generates valid PDF buffer', async () => {
const data = { totalUsers: 100, active: 80, pending: 15 }
const buffer = await generatePDF('users', data, '2025-01-01', '2025-01-31')
expect(buffer).toBeInstanceOf(Buffer)
expect(buffer.slice(0, 4).toString()).toBe('%PDF')
})
})
describe('Excel Generation', () => {
it('creates workbook with multiple sheets', () => {
const data = { overview: { total: 100 }, trends: [{ date: '2025-01-01', value: 10 }] }
const buffer = generateExcel('users', data, '2025-01-01', '2025-01-31')
const workbook = XLSX.read(buffer, { type: 'buffer' })
expect(workbook.SheetNames).toContain('Overview')
})
})
Integration Tests
Test the full export flow:
describe('Export API', () => {
it('exports PDF successfully', async () => {
const response = await fetch('/api/analytics/export?section=users&format=pdf&from=2025-01-01&to=2025-01-31')
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/pdf')
})
it('validates required parameters', async () => {
const response = await fetch('/api/analytics/export?section=users')
expect(response.status).toBe(400)
})
})
Lessons Learned
After implementing this export system in production:
What Worked Well
- QuickChart.io integration - Eliminated server-side chart rendering complexity
- Unified API endpoint - Single route handling all formats reduced maintenance
- Redis caching - 80% reduction in database queries for repeated exports
- Type safety - TypeScript caught format mismatches early
What We'd Do Differently
- Add progress indicators - Large exports feel frozen without feedback
- Implement background jobs - For very large datasets, queue exports
- Add export templates - Allow users to customize report layouts
- Compress large files - ZIP multiple files or compress single large exports
Performance Insights
- QuickChart.io adds 200-500ms per chart (acceptable trade-off)
- Excel generation is 3x faster than PDF with charts
- Caching reduced export time from 2.5s to 300ms on average
- PowerPoint generation is slowest due to image fetching
When to Use Each Format
| Format | Best For | Avoid When |
|---|---|---|
| Official documents, forms, legal records | Large tabular data (>1000 rows) | |
| Excel | Data analysis, financial reports, pivot tables | Complex layouts, branding |
| CSV | Data imports, API integrations, simple exports | Multiple data types, formatting |
| PowerPoint | Presentations, executive summaries, board meetings | Detailed data analysis |
Conclusion
Building a multi-format export system requires:
- Library selection - Choose proven, maintained libraries (jsPDF, XLSX, PptxGenJS)
- Server-side charts - Use QuickChart.io for consistent chart rendering
- Caching strategy - Redis caching for 5-minute TTL reduces load
- Audit logging - Track all exports for compliance
- Error handling - Graceful degradation for missing data
- Consistent styling - Brand colors and themes across all formats
The pattern shown here scales to various use cases:
- Financial reporting systems
- Government compliance documents
- Educational performance reports
- Healthcare analytics dashboards
The key is choosing the right format for each use case and optimizing for performance without sacrificing quality.
Want to Build a Custom Export System?
We have implemented multi-format export systems for various client projects, generating everything from request forms to board presentations. These systems handle thousands of monthly exports with sub-second response times.
Read our case study to see how we built a complete platform with comprehensive reporting capabilities.
Need help implementing PDF, Excel, or PowerPoint exports in your application? Schedule a consultation with our team.
Content Upgrade
Complete SaaS Development Checklist
Download our comprehensive 50-point checklist to build your SaaS product right the first time.
Download Free ChecklistFrootsyTech Solutions
Expert Software Development Team
Enterprise Software Development, Cloud Architecture, Full-Stack Engineering
FrootsyTech Solutions is an agile, expert-led software development agency specializing in web and mobile applications. Our team brings decades of combined experience in building scalable, production-ready solutions for businesses worldwide.
Related Articles
Building Multi-Step Form Wizards with React Hook Form and Zod
Learn how to build production-ready multi-step form wizards with React Hook Form and Zod validation. Includes step-by-step validation, progress tracking, data persistence, and file uploads with accessibility in mind.
Getting Started with Next.js 15: A Comprehensive Guide
Learn how to build modern web applications with Next.js 15, the React framework that makes building full-stack applications easier than ever.
Advanced TypeScript Patterns for Better Code Quality
Master advanced TypeScript patterns including conditional types, mapped types, and type guards to write more maintainable and type-safe code.