When building web apps with Next.js or Nuxt.js, the requirement to "export this page as PDF" or "let users download invoices and certificates" comes up constantly. Trying to generate PDFs purely on the client side runs into well-known problems: inconsistent CSS rendering, character encoding issues, and large file sizes.
The better approach is to use a server-side API that converts HTML to PDF. This guide walks you through integrating FUNBREW PDF into Next.js (App Router) and Nuxt 3 with practical code examples.
For the fundamentals of HTML-to-PDF conversion, see the HTML to PDF complete guide. For basic API usage across languages, check the language quickstart guide. For type-safe API usage with TypeScript, see the TypeScript PDF API Guide.
Why Use a PDF API in Frontend Frameworks
The browser's native print functionality (window.print()) and client-side libraries like jsPDF have real limitations:
| Problem | Details |
|---|---|
| CSS inconsistency | Print styles differ across browsers |
| Dynamic content | Can't include data requiring server-side auth |
| Performance | Heavy client-side rendering for complex documents |
| Security | Cannot expose API keys to the client |
Generating PDFs on the server solves all of these. Next.js API Routes and Nuxt 3 Server Routes are purpose-built for this pattern. For differences between wkhtmltopdf and Chromium rendering engines, see the engine comparison.
Common Prerequisites
Get Your API Key
Grab a FUNBREW PDF API key from the dashboard. Store it in an environment variable and never include it in client-side code.
For secure API key management best practices, see the security guide.
# .env.local
FUNBREW_PDF_API_KEY=sk-your-api-key
Install the Node.js SDK
npm install @funbrew/pdf
If you prefer raw HTTP calls, Node.js 18+ has native fetch built in — no extra dependencies needed.
Next.js (App Router)
Project Structure
app/
├── api/
│ └── generate-pdf/
│ └── route.ts # PDF generation endpoint
├── invoice/
│ └── page.tsx # Invoice page
└── components/
└── DownloadButton.tsx # PDF download button
API Route Implementation
Create the server-side PDF generation endpoint at app/api/generate-pdf/route.ts.
// app/api/generate-pdf/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'
const client = new FunbrewPdf({
apiKey: process.env.FUNBREW_PDF_API_KEY!,
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { html, options } = body
// Input validation
if (!html || typeof html !== 'string') {
return NextResponse.json(
{ error: 'html field is required' },
{ status: 400 }
)
}
const pdfBuffer = await client.generate({
html,
options: {
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
...options,
},
})
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="document.pdf"',
},
})
} catch (error) {
console.error('PDF generation error:', error)
return NextResponse.json(
{ error: 'Failed to generate PDF' },
{ status: 500 }
)
}
}
If you prefer making the HTTP call directly instead of using the SDK:
// Using native fetch
const response = await fetch('https://api.funbrew.dev/api/v1/generate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options }),
})
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
const pdfBuffer = await response.arrayBuffer()
Injecting Template Variables
The most common use case is building an HTML string from dynamic data before sending it to the API.
// app/api/generate-pdf/invoice/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'
const client = new FunbrewPdf({
apiKey: process.env.FUNBREW_PDF_API_KEY!,
})
function buildInvoiceHtml(data: {
invoiceNumber: string
customerName: string
items: Array<{ name: string; quantity: number; price: number }>
total: number
}): string {
const rows = data.items
.map(
(item) => `
<tr>
<td>${item.name}</td>
<td>${item.quantity}</td>
<td>$${item.price.toFixed(2)}</td>
<td>$${(item.quantity * item.price).toFixed(2)}</td>
</tr>`
)
.join('')
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: sans-serif; padding: 40px; }
h1 { color: #1a56db; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #e5e7eb; padding: 10px; text-align: left; }
th { background: #f9fafb; }
.total { font-size: 1.2em; font-weight: bold; text-align: right; margin-top: 20px; }
</style>
</head>
<body>
<h1>Invoice #${data.invoiceNumber}</h1>
<p>Bill to: ${data.customerName}</p>
<table>
<thead>
<tr><th>Item</th><th>Qty</th><th>Unit Price</th><th>Subtotal</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p class="total">Total: $${data.total.toFixed(2)}</p>
</body>
</html>`
}
export async function POST(request: NextRequest) {
const invoiceData = await request.json()
const html = buildInvoiceHtml(invoiceData)
const pdfBuffer = await client.generate({
html,
options: { format: 'A4' },
})
return new NextResponse(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${invoiceData.invoiceNumber}.pdf"`,
},
})
}
For more advanced template patterns, see the template engine guide.
Client-Side Download
From a client component, call the API Route and trigger a file download:
// app/components/DownloadButton.tsx
'use client'
import { useState } from 'react'
interface InvoiceData {
invoiceNumber: string
customerName: string
items: Array<{ name: string; quantity: number; price: number }>
total: number
}
export function DownloadPdfButton({ invoiceData }: { invoiceData: InvoiceData }) {
const [loading, setLoading] = useState(false)
const handleDownload = async () => {
setLoading(true)
try {
const response = await fetch('/api/generate-pdf/invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invoiceData),
})
if (!response.ok) {
throw new Error('Failed to generate PDF')
}
// Convert to Blob and trigger download
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `invoice-${invoiceData.invoiceNumber}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
alert('Failed to download PDF')
} finally {
setLoading(false)
}
}
return (
<button
onClick={handleDownload}
disabled={loading}
className="btn btn-primary"
>
{loading ? 'Generating...' : 'Download PDF'}
</button>
)
}
Dynamic Route for Direct PDF Access
You can also expose a GET endpoint for direct PDF access (useful for email links):
// app/invoice/[id]/download/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'
import { getInvoiceById } from '@/lib/db'
const client = new FunbrewPdf({
apiKey: process.env.FUNBREW_PDF_API_KEY!,
})
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
// Auth check (session/JWT)
// const session = await getServerSession()
// if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const invoice = await getInvoiceById(params.id)
if (!invoice) {
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
}
const html = buildInvoiceHtml(invoice)
const pdfBuffer = await client.generate({ html, options: { format: 'A4' } })
return new NextResponse(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="invoice-${params.id}.pdf"`,
},
})
}
Nuxt 3
Project Structure
server/
├── api/
│ └── generate-pdf.post.ts # PDF generation endpoint
└── utils/
└── pdf.ts # Shared utilities
pages/
└── invoice.vue # Invoice page
composables/
└── usePdf.ts # PDF composable
Server Route Implementation
Nuxt 3 uses filename conventions to match HTTP methods — the .post.ts suffix restricts the handler to POST requests.
// server/api/generate-pdf.post.ts
import { FunbrewPdf } from '@funbrew/pdf'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { html, options, filename = 'document.pdf' } = body
if (!html || typeof html !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'html field is required',
})
}
const apiKey = useRuntimeConfig().funbrewPdfApiKey
const client = new FunbrewPdf({ apiKey })
try {
const pdfBuffer = await client.generate({
html,
options: {
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
...options,
},
})
setResponseHeaders(event, {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
})
return pdfBuffer
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to generate PDF',
})
}
})
nuxt.config.ts — runtimeConfig Setup
Store the API key in server-only runtimeConfig. Anything under public is exposed to the client — never put API keys there.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Server-only (overridden by NUXT_FUNBREW_PDF_API_KEY env var)
funbrewPdfApiKey: process.env.FUNBREW_PDF_API_KEY ?? '',
public: {
// Client-accessible config only
},
},
})
# .env
NUXT_FUNBREW_PDF_API_KEY=sk-your-api-key
Composable for PDF Downloads
Encapsulate the download logic in a reusable composable:
// composables/usePdf.ts
export function usePdf() {
const loading = ref(false)
const error = ref<string | null>(null)
async function downloadPdf(
html: string,
options?: Record<string, unknown>,
filename = 'document.pdf'
) {
loading.value = true
error.value = null
try {
const response = await $fetch<Blob>('/api/generate-pdf', {
method: 'POST',
body: { html, options, filename },
responseType: 'blob',
})
const url = URL.createObjectURL(response)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch (err) {
error.value = 'Failed to download PDF'
console.error(err)
} finally {
loading.value = false
}
}
return { loading, error, downloadPdf }
}
Nuxt 3 Page Component
<!-- pages/invoice.vue -->
<script setup lang="ts">
const { loading, error, downloadPdf } = usePdf()
const invoiceData = ref({
number: 'INV-2026-001',
customer: 'Acme Corp',
total: 1500.00,
})
async function handleDownload() {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>body { font-family: sans-serif; padding: 40px; }</style>
</head>
<body>
<h1>Invoice #${invoiceData.value.number}</h1>
<p>Bill to: ${invoiceData.value.customer}</p>
<p>Total: $${invoiceData.value.total.toFixed(2)}</p>
</body>
</html>`
await downloadPdf(html, { format: 'A4' }, `invoice-${invoiceData.value.number}.pdf`)
}
</script>
<template>
<div>
<h1>Invoice Management</h1>
<p v-if="error" class="text-red-500">{{ error }}</p>
<button
:disabled="loading"
@click="handleDownload"
class="btn btn-primary"
>
{{ loading ? 'Generating...' : 'Download PDF' }}
</button>
</div>
</template>
Error Handling
Robust error handling is essential for production PDF generation. See the error handling guide for comprehensive patterns.
Retry Logic (Next.js)
// lib/pdf.ts
import { FunbrewPdf } from '@funbrew/pdf'
const client = new FunbrewPdf({
apiKey: process.env.FUNBREW_PDF_API_KEY!,
})
export async function generatePdfWithRetry(
html: string,
options = {},
maxRetries = 3
): Promise<ArrayBuffer> {
let lastError: Error | undefined
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await client.generate({ html, options })
} catch (error) {
lastError = error as Error
if (attempt < maxRetries) {
// Exponential backoff
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt))
}
}
}
throw lastError
}
HTTP Status Code Reference
| Status Code | Meaning | Action |
|---|---|---|
| 400 | Malformed HTML or bad request | Fix the request body |
| 401 | Invalid API key | Check environment variable |
| 429 | Rate limit exceeded | Back off and retry |
| 500 | Server error | Retry with backoff |
Production Deployment
Deploying to Vercel (Next.js)
Add environment variables in the Vercel project settings. Never use the NEXT_PUBLIC_ prefix for the API key — that would expose it to the browser.
# Add via Vercel CLI
vercel env add FUNBREW_PDF_API_KEY production
Timeout configuration: PDF generation can take a few seconds for complex documents. Vercel's free tier limits Serverless Functions to 10 seconds. For large documents, the Pro plan supports up to 60 seconds:
// app/api/generate-pdf/route.ts
export const maxDuration = 60 // seconds (Pro plan only)
Deploying to Netlify (Nuxt 3)
Nuxt 3 runs on Netlify's Serverless Functions. Configure the bundler to include the SDK:
# netlify.toml
[functions]
node_bundler = "esbuild"
external_node_modules = ["@funbrew/pdf"]
# Set environment variable
netlify env:set NUXT_FUNBREW_PDF_API_KEY "sk-your-api-key"
Self-Hosted Deployment
Whether you're running on a VPS or bare metal, the same security principles apply:
- Manage API keys via environment variables (never commit
.envto Git) - Apply rate limiting to the PDF endpoint
- Set maximum document size limits to protect memory
For serverless-specific deployment patterns, see the serverless PDF generation guide. For a full production readiness checklist, see the production guide. Docker/Kubernetes deployment is covered in the Docker & Kubernetes Guide. For print-specific CSS optimization, see PDF CSS Layout Tips.
Batch PDF Generation
When you need to generate multiple PDFs at once, use parallel processing with concurrency limits:
// app/api/generate-pdfs-batch/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'
const client = new FunbrewPdf({
apiKey: process.env.FUNBREW_PDF_API_KEY!,
})
export async function POST(request: NextRequest) {
const { documents } = await request.json()
// Process in chunks of 5 (concurrency limit)
const chunks = []
for (let i = 0; i < documents.length; i += 5) {
chunks.push(documents.slice(i, i + 5))
}
const results = []
for (const chunk of chunks) {
const pdfs = await Promise.allSettled(
chunk.map((doc: { html: string; filename: string }) =>
client.generate({ html: doc.html, options: { format: 'A4' } })
)
)
results.push(...pdfs)
}
return NextResponse.json({
total: results.length,
success: results.filter((r) => r.status === 'fulfilled').length,
failed: results.filter((r) => r.status === 'rejected').length,
})
}
For high-volume PDF generation patterns, see the batch processing guide.
Summary
Key takeaways for integrating FUNBREW PDF into Next.js and Nuxt 3:
- API keys stay server-side only — never use
NEXT_PUBLIC_orruntimeConfig.public - Encapsulate PDF logic in API Routes or Server Routes
- Client components use the Blob download pattern
- Watch timeout limits on Vercel and Netlify for large documents
- Implement retry logic for production reliability
Try your HTML templates in the playground before starting implementation. For use case inspiration, browse the use cases page.
For invoice automation see the invoice PDF automation guide, and for certificate generation see the certificate automation guide. For Markdown-based PDF generation, check the Markdown to PDF API Guide. Automated report generation is covered in the Report PDF Generation Guide. Compare plans and pricing at the Pricing Comparison.