Skip to main content

Command Palette

Search for a command to run...

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.

Updated
8 min read
Adding PDF Export to Your SaaS in an Afternoon

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:

  1. Create an HTML template with Handlebars/EJS

  2. Add Puppeteer or Playwright as a dependency

  3. Spin up a headless Chrome instance

  4. Render the HTML and export to PDF

  5. Handle Chrome memory leaks, version conflicts, and cold start times

  6. 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.

The integration pattern: your SaaS app calls Rynko's SDK, gets back a download URL in under 500ms. No browser, no infrastructure.

Step 1: Design Your Templates

The visual template designer — drag and drop components, define variables, preview in real time. No HTML required.

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>
PDF and Excel download buttons side by side — one template, two export options for your users.

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

  1. Sign up for Rynko — free with 5,000 credits

  2. Design your export templates in the visual editor

  3. npm install @rynko/sdk (or pip install rynko)

  4. Add the document service and API endpoints

  5. 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.

More from this blog

B

Building Rynko

18 posts

How to Add PDF Export to Your SaaS in One Afternoon (Node.js & Next.js