Development

Generating PDFs, Excel, and PowerPoint Reports in Next.js

FrootsyTech Solutions
13 min read

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

  1. QuickChart.io integration - Eliminated server-side chart rendering complexity
  2. Unified API endpoint - Single route handling all formats reduced maintenance
  3. Redis caching - 80% reduction in database queries for repeated exports
  4. Type safety - TypeScript caught format mismatches early

What We'd Do Differently

  1. Add progress indicators - Large exports feel frozen without feedback
  2. Implement background jobs - For very large datasets, queue exports
  3. Add export templates - Allow users to customize report layouts
  4. 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

FormatBest ForAvoid When
PDFOfficial documents, forms, legal recordsLarge tabular data (>1000 rows)
ExcelData analysis, financial reports, pivot tablesComplex layouts, branding
CSVData imports, API integrations, simple exportsMultiple data types, formatting
PowerPointPresentations, executive summaries, board meetingsDetailed 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

Free Checklist

Complete SaaS Development Checklist

Download our comprehensive 50-point checklist to build your SaaS product right the first time.

Download Free Checklist

Share this article

FrootsyTech Solutions

FrootsyTech Solutions

Technology Solutions Team

Enterprise Software Development, Cloud Architecture, Full-Stack Engineering

FrootsyTech Solutions is a technology partner helping businesses build custom applications, automate operations, and leverage data for smarter decisions. Our team brings decades of combined experience delivering scalable, production-ready solutions worldwide.