Adding PDF Export to Your SaaS in an Afternoon
Your users want to download reports, invoices, and data exports as PDFs. Here's how to add that feature in a few hours, not a few weeks.

It's a feature request that shows up in every SaaS app eventually: "Can I download this as a PDF?"
Users want to export dashboards, download invoices, save reports, and share formatted documents. And as a developer, you know this means dealing with PDF generation — one of those tasks that sounds simple but turns into a rabbit hole of browser dependencies, layout quirks, and maintenance headaches.
This guide shows you how to add PDF (and Excel) export to your SaaS in an afternoon using Rynko, without adding Puppeteer to your stack.
The Traditional Approach (and Why It Hurts)
Most teams approach PDF export like this:
Create an HTML template with Handlebars/EJS
Add Puppeteer or Playwright as a dependency
Spin up a headless Chrome instance
Render the HTML and export to PDF
Handle Chrome memory leaks, version conflicts, and cold start times
Scale the whole thing when you have more than a few concurrent exports
The result is a PDF service that:
Takes 3-8 seconds per document
Requires 200-500MB RAM per Chrome instance
Breaks when Chrome auto-updates
Needs its own scaling strategy
Makes your Docker images huge
And when product asks "can we also export as Excel?" — you start from scratch because Puppeteer doesn't do spreadsheets.
The Rynko Approach
Instead, you design a template once (visual editor or API), and call our SDK to generate documents. No browser. Sub-500ms generation. PDF and Excel from the same template.
Here's the full integration pattern.
Step 1: Design Your Templates
Before writing any code, create the templates your users will need. Common SaaS export templates:
| Template | Use Case |
|---|---|
| Invoice | Billing, payments |
| Usage Report | Monthly account summaries |
| Data Export | Table data as formatted PDF/Excel |
| Dashboard Summary | KPI snapshot with charts |
| Certificate | Course completion, achievements |
| Contract | User agreements, proposals |
Use the visual designer at app.rynko.dev to build each template. Define variables that match your application's data model.
For example, a "Usage Report" template might have these variables:
interface UsageReportVariables {
accountName: string;
reportPeriod: string;
totalApiCalls: number;
totalDocuments: number;
storageUsedMb: number;
dailyUsage: Array<{ date: string; apiCalls: number; documents: number }>;
topEndpoints: Array<{ endpoint: string; calls: number; avgLatencyMs: number }>;
}
Step 2: Create a Document Service
Add a thin service layer that handles document generation:
// src/services/document.service.ts
import { Rynko } from '@rynko/sdk';
const rynko = new Rynko({ apiKey: process.env.RYNKO_API_KEY! });
// Template IDs - map to your Rynko templates
const TEMPLATES = {
invoice: 'invoice',
usageReport: 'usage-report',
dataExport: 'data-export',
dashboardSummary: 'dashboard-summary',
} as const;
type TemplateKey = keyof typeof TEMPLATES;
interface GenerateOptions {
template: TemplateKey;
variables: Record<string, any>;
format?: 'pdf' | 'excel';
filename?: string;
}
export async function generateDocument(options: GenerateOptions) {
const { template, variables, format = 'pdf' } = options;
const params = { templateId: TEMPLATES[template], variables };
const job = format === 'excel'
? await rynko.documents.generateExcel(params)
: await rynko.documents.generatePdf(params);
const completed = await rynko.documents.waitForCompletion(job.jobId);
if (completed.status !== 'completed') {
throw new Error(`Document generation failed: ${completed.errorMessage}`);
}
return {
downloadUrl: completed.downloadUrl,
jobId: job.jobId,
format,
};
}
Step 3: Add API Endpoints
Express / Node.js
// src/routes/exports.ts
import { Router } from 'express';
import { generateDocument } from '../services/document.service';
import { requireAuth } from '../middleware/auth';
const router = Router();
// Download invoice as PDF
router.get('/invoices/:id/pdf', requireAuth, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
if (!invoice || invoice.accountId !== req.user.accountId) {
return res.status(404).json({ error: 'Invoice not found' });
}
const result = await generateDocument({
template: 'invoice',
format: 'pdf',
variables: {
invoiceNumber: invoice.number,
date: invoice.date,
customerName: invoice.customerName,
items: invoice.lineItems,
subtotal: invoice.subtotal,
tax: invoice.tax,
total: invoice.total,
},
});
res.json({ downloadUrl: result.downloadUrl });
});
// Download usage report (PDF or Excel)
router.get('/reports/usage', requireAuth, async (req, res) => {
const format = req.query.format === 'excel' ? 'excel' : 'pdf';
const period = req.query.period as string || 'current-month';
const usage = await db.usage.getReport(req.user.accountId, period);
const result = await generateDocument({
template: 'usageReport',
format,
variables: {
accountName: req.user.accountName,
reportPeriod: usage.periodLabel,
totalApiCalls: usage.totalApiCalls,
totalDocuments: usage.totalDocuments,
storageUsedMb: usage.storageUsedMb,
dailyUsage: usage.dailyBreakdown,
topEndpoints: usage.topEndpoints,
},
});
res.json({ downloadUrl: result.downloadUrl });
});
// Generic data export
router.post('/exports', requireAuth, async (req, res) => {
const { type, filters, format = 'pdf' } = req.body;
const data = await db.exports.getData(req.user.accountId, type, filters);
const result = await generateDocument({
template: 'dataExport',
format,
variables: {
exportTitle: `${type} Export`,
exportDate: new Date().toISOString().split('T')[0],
columns: data.columns,
rows: data.rows,
totalRows: data.totalRows,
},
});
res.json({ downloadUrl: result.downloadUrl });
});
export default router;
Next.js API Routes
// app/api/invoices/[id]/pdf/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { generateDocument } from '@/lib/document-service';
import { getServerSession } from 'next-auth';
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const invoice = await db.invoices.findById(params.id);
if (!invoice) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const result = await generateDocument({
template: 'invoice',
format: 'pdf',
variables: mapInvoiceToVariables(invoice),
});
return NextResponse.json({ downloadUrl: result.downloadUrl });
}
Step 4: Add Frontend Download Buttons
// components/DownloadButton.tsx
'use client';
import { useState } from 'react';
import { Download, FileSpreadsheet } from 'lucide-react';
interface DownloadButtonProps {
endpoint: string;
format: 'pdf' | 'excel';
label?: string;
}
export function DownloadButton({ endpoint, format, label }: DownloadButtonProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDownload = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`\({endpoint}?format=\){format}`);
if (!res.ok) throw new Error('Export failed');
const { downloadUrl } = await res.json();
window.open(downloadUrl, '_blank');
} catch (err) {
setError('Export failed. Please try again.');
} finally {
setLoading(false);
}
};
const Icon = format === 'excel' ? FileSpreadsheet : Download;
const text = label || `Download ${format.toUpperCase()}`;
return (
<div>
<button onClick={handleDownload} disabled={loading} className="btn btn-secondary">
<Icon className="h-4 w-4 mr-2" />
{loading ? 'Generating...' : text}
</button>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
);
}
Usage in your pages:
// In your invoice detail page
<DownloadButton endpoint={`/api/invoices/${invoice.id}/pdf`} format="pdf" />
// In your reports page — offer both formats
<div className="flex gap-2">
<DownloadButton endpoint="/api/reports/usage" format="pdf" label="PDF Report" />
<DownloadButton endpoint="/api/reports/usage" format="excel" label="Excel Export" />
</div>
Step 5: Handle Background Generation (Optional)
For large reports or batch exports, use webhooks instead of waiting synchronously:
// Start generation without waiting
export async function queueDocumentGeneration(options: GenerateOptions & { userId: string }) {
const params = {
templateId: TEMPLATES[options.template],
variables: options.variables,
};
const job = options.format === 'excel'
? await rynko.documents.generateExcel(params)
: await rynko.documents.generatePdf(params);
// Store the job reference
await db.exportJobs.create({
jobId: job.jobId,
userId: options.userId,
status: 'processing',
});
return job.jobId;
}
// Webhook handler — called when document is ready
import { verifyWebhookSignature } from '@rynko/sdk';
app.post('/webhooks/rynko', express.raw({ type: 'application/json' }), async (req, res) => {
const event = verifyWebhookSignature({
payload: req.body.toString(),
signature: req.headers['x-rynko-signature'] as string,
secret: process.env.RYNKO_WEBHOOK_SECRET!,
});
if (event.type === 'document.generated') {
await db.exportJobs.update(event.data.jobId, {
status: 'completed',
downloadUrl: event.data.downloadUrl,
});
// Notify user (in-app notification, email, etc.)
await notifyUser(event.data.metadata.userId, {
title: 'Your export is ready',
downloadUrl: event.data.downloadUrl,
});
}
res.json({ received: true });
});
What This Gets You
After an afternoon of integration:
PDF export on any page that displays data
Excel export from the same templates — no separate implementation
Sub-500ms generation — users don't wait
No browser dependencies — no Puppeteer, no Chrome, no memory issues
Template changes without deploys — update templates in the visual editor, no code changes needed. Stop wasting engineering sprints on "Can we move the logo 5px to the left?" — give the visual editor to your designer and never touch the PDF code again.
Non-developers can update templates — product or design teams can modify document layouts directly
Cost at Scale
| Monthly exports | Rynko plan | Cost | Per-document |
|---|---|---|---|
| Up to 50 | Free | $0 | $0 |
| Up to 600 | Starter | $29/mo | $0.048 |
| Up to 4,000 | Growth | $79/mo | $0.020 |
| Up to 12,000 | Scale | $149/mo | $0.012 |
Compare that to Puppeteer infrastructure costs at scale, plus the engineering time to maintain it.
Get Started
Sign up for Rynko — free with 5,000 credits
Design your export templates in the visual editor
npm install @rynko/sdk(orpip install rynko)Add the document service and API endpoints
Add download buttons to your frontend
Your users get their PDF exports. You get your afternoon back.
Get Started Free | SDK Documentation | API Reference
New to Rynko? Follow our Getting Started in 5 Minutes guide. Want to understand the rendering architecture? Read Design Once, Generate Anywhere.
Building something specific? Check our use cases for industry-specific examples and templates.
Disclosure: I ideate and draft content, then refine it with the aid of artificial intelligence tools like Claude and revise it to reflect my intended message.





