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

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.