Back to Blog

React PDF Generation in 2026: @react-pdf/renderer, Server-Side Rendering, and S3 Upload

Generate PDFs in Next.js with @react-pdf/renderer: invoice templates, server-side rendering, streaming downloads, S3 upload, and background generation for large documents.

Viprasol Tech Team
December 30, 2026
13 min read

React PDF Generation in 2026: @react-pdf/renderer, Server-Side Rendering, and S3 Upload

PDF generation is a deceptively common requirement: invoices, contracts, reports, certificates, statements. The challenge is doing it wellβ€”matching your brand, handling page breaks, supporting multiple pages, and not blocking your API while a 50-page report renders.

@react-pdf/renderer solves the first part: it lets you describe PDFs using React components with a Flexbox layout model, rendered server-side to actual PDF bytes. This post covers the complete production setup: invoice template, API route for on-demand generation, S3 upload for storage, and a background job pattern for large documents.


Installation

npm install @react-pdf/renderer
npm install --save-dev @types/react-pdf

Important: @react-pdf/renderer must run in Node.js, not in the browser. It uses native Node APIs for font loading and canvas operations. Mark components with "use server" or keep them in Server Components / API routes.


Core Concepts

@react-pdf/renderer uses its own component setβ€”not HTML elements:

HTML@react-pdfNotes
<div><View>Main layout container
<p>, <span><Text>All text must be in <Text>
<img><Image>src must be URL or base64
<a><Link>Clickable PDF links
<table>Nested <View>Tables are built with flex
CSSStyleSheet.create()Subset of CSS: flex, font, color, margin

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Invoice Template

// lib/pdf/templates/Invoice.tsx
import {
  Document,
  Page,
  View,
  Text,
  Image,
  StyleSheet,
  Font,
} from "@react-pdf/renderer";

// Register a custom font (optional)
Font.register({
  family: "Inter",
  fonts: [
    { src: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff", fontWeight: 400 },
    { src: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuGKYAZ9hiJ-Ek-_EeA.woff", fontWeight: 600 },
    { src: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYAZ9hiJ-Ek-_EeA.woff", fontWeight: 700 },
  ],
});

const styles = StyleSheet.create({
  page: {
    fontFamily: "Inter",
    fontSize: 10,
    color: "#111827",
    padding: 40,
    backgroundColor: "#ffffff",
  },
  header: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "flex-start",
    marginBottom: 40,
  },
  logo: {
    width: 120,
    height: 40,
    objectFit: "contain",
  },
  headerRight: {
    alignItems: "flex-end",
  },
  invoiceTitle: {
    fontSize: 28,
    fontWeight: 700,
    color: "#1d4ed8",
    letterSpacing: -0.5,
  },
  invoiceNumber: {
    fontSize: 11,
    color: "#6b7280",
    marginTop: 4,
  },
  metaSection: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 32,
  },
  metaBlock: {
    flex: 1,
  },
  metaLabel: {
    fontSize: 9,
    fontWeight: 600,
    color: "#6b7280",
    textTransform: "uppercase",
    letterSpacing: 0.5,
    marginBottom: 4,
  },
  metaValue: {
    fontSize: 10,
    color: "#111827",
    lineHeight: 1.5,
  },
  statusBadge: {
    alignSelf: "flex-start",
    backgroundColor: "#dcfce7",
    color: "#166534",
    paddingHorizontal: 8,
    paddingVertical: 3,
    borderRadius: 4,
    fontSize: 9,
    fontWeight: 600,
    textTransform: "uppercase",
    letterSpacing: 0.5,
    marginTop: 8,
  },
  table: {
    marginBottom: 24,
  },
  tableHeader: {
    flexDirection: "row",
    backgroundColor: "#f9fafb",
    borderRadius: 4,
    paddingVertical: 8,
    paddingHorizontal: 12,
    marginBottom: 4,
  },
  tableRow: {
    flexDirection: "row",
    paddingVertical: 10,
    paddingHorizontal: 12,
    borderBottomWidth: 1,
    borderBottomColor: "#f3f4f6",
  },
  col: {
    flex: 1,
  },
  colNarrow: {
    width: 80,
    textAlign: "right",
  },
  colDescription: {
    flex: 3,
  },
  headerCell: {
    fontSize: 9,
    fontWeight: 600,
    color: "#6b7280",
    textTransform: "uppercase",
    letterSpacing: 0.4,
  },
  cell: {
    fontSize: 10,
    color: "#374151",
  },
  cellMuted: {
    fontSize: 9,
    color: "#9ca3af",
    marginTop: 2,
  },
  totalsSection: {
    alignItems: "flex-end",
    marginBottom: 32,
  },
  totalRow: {
    flexDirection: "row",
    width: 240,
    justifyContent: "space-between",
    paddingVertical: 4,
  },
  totalLabel: {
    fontSize: 10,
    color: "#6b7280",
  },
  totalValue: {
    fontSize: 10,
    color: "#111827",
    textAlign: "right",
    width: 80,
  },
  totalDivider: {
    width: 240,
    borderTopWidth: 1,
    borderTopColor: "#e5e7eb",
    marginVertical: 8,
  },
  grandTotalRow: {
    flexDirection: "row",
    width: 240,
    justifyContent: "space-between",
    paddingVertical: 4,
  },
  grandTotalLabel: {
    fontSize: 12,
    fontWeight: 700,
    color: "#111827",
  },
  grandTotalValue: {
    fontSize: 12,
    fontWeight: 700,
    color: "#1d4ed8",
    textAlign: "right",
    width: 80,
  },
  notes: {
    marginTop: 16,
    padding: 16,
    backgroundColor: "#f9fafb",
    borderRadius: 4,
    borderLeftWidth: 3,
    borderLeftColor: "#1d4ed8",
  },
  notesLabel: {
    fontSize: 9,
    fontWeight: 600,
    color: "#6b7280",
    textTransform: "uppercase",
    letterSpacing: 0.4,
    marginBottom: 4,
  },
  notesText: {
    fontSize: 10,
    color: "#374151",
    lineHeight: 1.5,
  },
  footer: {
    position: "absolute",
    bottom: 30,
    left: 40,
    right: 40,
    flexDirection: "row",
    justifyContent: "space-between",
    borderTopWidth: 1,
    borderTopColor: "#e5e7eb",
    paddingTop: 12,
  },
  footerText: {
    fontSize: 8,
    color: "#9ca3af",
  },
});

export interface InvoiceData {
  number: string;
  status: "draft" | "sent" | "paid" | "overdue";
  issuedAt: string;
  dueAt: string;
  from: {
    name: string;
    address: string;
    email: string;
    taxId?: string;
  };
  to: {
    name: string;
    address: string;
    email: string;
    taxId?: string;
  };
  lineItems: Array<{
    description: string;
    detail?: string;
    quantity: number;
    unitPrice: number; // cents
    total: number;     // cents
    taxRate?: number;
  }>;
  subtotal: number;   // cents
  taxTotal: number;   // cents
  total: number;      // cents
  currency: string;
  notes?: string;
  logoUrl?: string;
}

function formatCurrency(cents: number, currency: string): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  }).format(cents / 100);
}

function formatDate(iso: string): string {
  return new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(
    new Date(iso)
  );
}

const statusColors: Record<InvoiceData["status"], { bg: string; color: string }> = {
  draft: { bg: "#f3f4f6", color: "#374151" },
  sent: { bg: "#dbeafe", color: "#1d4ed8" },
  paid: { bg: "#dcfce7", color: "#166534" },
  overdue: { bg: "#fee2e2", color: "#991b1b" },
};

export function InvoicePDF({ data }: { data: InvoiceData }) {
  const statusStyle = statusColors[data.status];

  return (
    <Document
      title={`Invoice ${data.number}`}
      author={data.from.name}
      subject={`Invoice for ${data.to.name}`}
    >
      <Page size="A4" style={styles.page}>
        {/* Header */}
        <View style={styles.header}>
          <View>
            {data.logoUrl ? (
              <Image src={data.logoUrl} style={styles.logo} />
            ) : (
              <Text style={{ fontSize: 18, fontWeight: 700, color: "#1d4ed8" }}>
                {data.from.name}
              </Text>
            )}
          </View>
          <View style={styles.headerRight}>
            <Text style={styles.invoiceTitle}>INVOICE</Text>
            <Text style={styles.invoiceNumber}>{data.number}</Text>
            <View style={{ ...styles.statusBadge, backgroundColor: statusStyle.bg }}>
              <Text style={{ color: statusStyle.color }}>
                {data.status.toUpperCase()}
              </Text>
            </View>
          </View>
        </View>

        {/* Meta section */}
        <View style={styles.metaSection}>
          <View style={styles.metaBlock}>
            <Text style={styles.metaLabel}>From</Text>
            <Text style={styles.metaValue}>{data.from.name}</Text>
            <Text style={styles.metaValue}>{data.from.address}</Text>
            <Text style={styles.metaValue}>{data.from.email}</Text>
            {data.from.taxId && (
              <Text style={styles.metaValue}>Tax ID: {data.from.taxId}</Text>
            )}
          </View>
          <View style={styles.metaBlock}>
            <Text style={styles.metaLabel}>Bill To</Text>
            <Text style={styles.metaValue}>{data.to.name}</Text>
            <Text style={styles.metaValue}>{data.to.address}</Text>
            <Text style={styles.metaValue}>{data.to.email}</Text>
            {data.to.taxId && (
              <Text style={styles.metaValue}>Tax ID: {data.to.taxId}</Text>
            )}
          </View>
          <View style={styles.metaBlock}>
            <Text style={styles.metaLabel}>Invoice Date</Text>
            <Text style={styles.metaValue}>{formatDate(data.issuedAt)}</Text>
            <View style={{ marginTop: 12 }}>
              <Text style={styles.metaLabel}>Due Date</Text>
              <Text style={styles.metaValue}>{formatDate(data.dueAt)}</Text>
            </View>
          </View>
        </View>

        {/* Line items table */}
        <View style={styles.table}>
          <View style={styles.tableHeader}>
            <Text style={{ ...styles.headerCell, ...styles.colDescription }}>Description</Text>
            <Text style={{ ...styles.headerCell, ...styles.colNarrow }}>Qty</Text>
            <Text style={{ ...styles.headerCell, ...styles.colNarrow }}>Unit Price</Text>
            <Text style={{ ...styles.headerCell, ...styles.colNarrow }}>Total</Text>
          </View>

          {data.lineItems.map((item, index) => (
            <View key={index} style={styles.tableRow}>
              <View style={styles.colDescription}>
                <Text style={styles.cell}>{item.description}</Text>
                {item.detail && (
                  <Text style={styles.cellMuted}>{item.detail}</Text>
                )}
              </View>
              <Text style={{ ...styles.cell, ...styles.colNarrow }}>
                {item.quantity}
              </Text>
              <Text style={{ ...styles.cell, ...styles.colNarrow }}>
                {formatCurrency(item.unitPrice, data.currency)}
              </Text>
              <Text style={{ ...styles.cell, ...styles.colNarrow }}>
                {formatCurrency(item.total, data.currency)}
              </Text>
            </View>
          ))}
        </View>

        {/* Totals */}
        <View style={styles.totalsSection}>
          <View style={styles.totalRow}>
            <Text style={styles.totalLabel}>Subtotal</Text>
            <Text style={styles.totalValue}>
              {formatCurrency(data.subtotal, data.currency)}
            </Text>
          </View>
          {data.taxTotal > 0 && (
            <View style={styles.totalRow}>
              <Text style={styles.totalLabel}>Tax</Text>
              <Text style={styles.totalValue}>
                {formatCurrency(data.taxTotal, data.currency)}
              </Text>
            </View>
          )}
          <View style={styles.totalDivider} />
          <View style={styles.grandTotalRow}>
            <Text style={styles.grandTotalLabel}>Total Due</Text>
            <Text style={styles.grandTotalValue}>
              {formatCurrency(data.total, data.currency)}
            </Text>
          </View>
        </View>

        {/* Notes */}
        {data.notes && (
          <View style={styles.notes}>
            <Text style={styles.notesLabel}>Notes</Text>
            <Text style={styles.notesText}>{data.notes}</Text>
          </View>
        )}

        {/* Footer */}
        <View style={styles.footer} fixed>
          <Text style={styles.footerText}>{data.from.name}</Text>
          <Text style={styles.footerText}>
            {data.number} Β· {formatDate(data.issuedAt)}
          </Text>
          <Text
            style={styles.footerText}
            render={({ pageNumber, totalPages }) =>
              `Page ${pageNumber} of ${totalPages}`
            }
          />
        </View>
      </Page>
    </Document>
  );
}

API Route: Stream PDF to Browser

// app/api/invoices/[id]/pdf/route.ts
import { NextRequest, NextResponse } from "next/server";
import { renderToBuffer } from "@react-pdf/renderer";
import { getCurrentUser } from "@/lib/auth";
import { getInvoiceData } from "@/lib/invoices";
import { InvoicePDF } from "@/lib/pdf/templates/Invoice";
import { createElement } from "react";

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { id } = await params;
  const invoiceData = await getInvoiceData(id, user.id);

  if (!invoiceData) {
    return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
  }

  // Render PDF to buffer (server-side only)
  const pdfBuffer = await renderToBuffer(
    createElement(InvoicePDF, { data: invoiceData })
  );

  const filename = `invoice-${invoiceData.number}.pdf`;

  return new NextResponse(pdfBuffer, {
    status: 200,
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `attachment; filename="${filename}"`,
      "Content-Length": String(pdfBuffer.length),
      // Cache for 5 minutes if invoice is finalized
      "Cache-Control": invoiceData.status === "paid"
        ? "private, max-age=300"
        : "no-store",
    },
  });
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Upload to S3 for Persistent Storage

// lib/pdf/storage.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { InvoicePDF, InvoiceData } from "./templates/Invoice";

const s3 = new S3Client({ region: process.env.AWS_REGION! });
const BUCKET = process.env.PDF_STORAGE_BUCKET!;

export async function generateAndStoreInvoicePDF(
  invoiceData: InvoiceData,
  invoiceId: string
): Promise<{ s3Key: string; sizeBytes: number }> {
  const pdfBuffer = await renderToBuffer(
    createElement(InvoicePDF, { data: invoiceData })
  );

  const s3Key = `invoices/${new Date().getFullYear()}/${invoiceId}.pdf`;

  await s3.send(
    new PutObjectCommand({
      Bucket: BUCKET,
      Key: s3Key,
      Body: pdfBuffer,
      ContentType: "application/pdf",
      ContentLength: pdfBuffer.length,
      ServerSideEncryption: "aws:kms",
      Metadata: {
        invoiceNumber: invoiceData.number,
        invoiceId,
        generatedAt: new Date().toISOString(),
      },
    })
  );

  return { s3Key, sizeBytes: pdfBuffer.length };
}

export async function getInvoicePDFDownloadUrl(
  s3Key: string,
  invoiceNumber: string,
  expiresIn = 3600
): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: BUCKET,
    Key: s3Key,
    ResponseContentDisposition: `attachment; filename="invoice-${invoiceNumber}.pdf"`,
    ResponseContentType: "application/pdf",
  });

  return getSignedUrl(s3, command, { expiresIn });
}

Background Generation for Large Reports

For multi-page reports that take >2 seconds to generate, use a background job:

// jobs/handlers/report-pdf.handler.ts
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { ReportPDF } from "@/lib/pdf/templates/Report";
import { generateAndStoreReportPDF } from "@/lib/pdf/storage";
import { db } from "@/lib/db";
import { sendReportReadyEmail } from "@/lib/email";

interface ReportPDFJob {
  reportId: string;
  userId: string;
  reportType: string;
  dateRange: { from: string; to: string };
}

export async function handleReportPDFGeneration(payload: ReportPDFJob) {
  const { reportId, userId, reportType, dateRange } = payload;

  // Update status to generating
  await db
    .updateTable("reports")
    .set({ status: "generating", started_at: new Date() })
    .where("id", "=", reportId)
    .execute();

  try {
    // Fetch report data (may be slow for large date ranges)
    const reportData = await buildReportData(reportId, dateRange);

    // Generate PDF
    const { s3Key, sizeBytes } = await generateAndStoreReportPDF(
      reportData,
      reportId
    );

    // Mark complete and store download key
    await db
      .updateTable("reports")
      .set({
        status: "ready",
        pdf_s3_key: s3Key,
        pdf_size_bytes: sizeBytes,
        completed_at: new Date(),
      })
      .where("id", "=", reportId)
      .execute();

    // Notify user
    const user = await db
      .selectFrom("users")
      .select(["email", "name"])
      .where("id", "=", userId)
      .executeTakeFirstOrThrow();

    await sendReportReadyEmail({
      to: user.email,
      name: user.name,
      reportType,
      reportId,
      downloadUrl: `${process.env.NEXT_PUBLIC_APP_URL}/reports/${reportId}/download`,
    });
  } catch (err) {
    await db
      .updateTable("reports")
      .set({ status: "failed", failed_at: new Date() })
      .where("id", "=", reportId)
      .execute();

    throw err;
  }
}

Common Issues and Fixes

Image loading: @react-pdf/renderer requires image URLs to be accessible at render time. For private S3 images, generate a presigned URL before passing to the template.

Font loading: Register fonts before rendering. Use WOFF format (not WOFF2) β€” WOFF2 has limited support in @react-pdf.

Page breaks: Use break prop or wrap={false} on elements you don't want split across pages.

Performance: renderToBuffer() is synchronous and CPU-intensive. For production, run in a worker thread or separate process.

// lib/pdf/worker.ts β€” run in Worker thread for CPU isolation
import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";

if (!isMainThread) {
  // This runs in the Worker thread
  const { templateName, data } = workerData;
  
  import(`./templates/${templateName}`).then(({ default: Template }) => {
    renderToBuffer(createElement(Template, { data }))
      .then((buffer) => parentPort!.postMessage({ buffer }))
      .catch((err) => parentPort!.postMessage({ error: err.message }));
  });
}

export function renderPDFInWorker(
  templateName: string,
  data: unknown
): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(__filename, { workerData: { templateName, data } });
    worker.on("message", ({ buffer, error }) => {
      if (error) reject(new Error(error));
      else resolve(Buffer.from(buffer));
    });
    worker.on("error", reject);
  });
}

Cost and Timeline Estimates

ComponentTimelineCost (USD)
Single PDF template (invoice)1–2 days$800–$1,600
API route + streaming download0.5–1 day$400–$800
S3 storage + presigned URLs0.5 day$400
Background generation + email notify1–2 days$800–$1,600
Multi-template report system1–2 weeks$5,000–$10,000

AWS S3 storage cost for PDFs: ~$0.023/GB/month. A 100KB invoice Γ— 10,000 invoices = 1GB = $0.023/month. Negligible.


See Also


Working With Viprasol

We build document generation systems for SaaS productsβ€”from simple invoice PDFs through complex multi-page reports with charts, tables, and conditional content. Our engineers have shipped PDF generation systems handling tens of thousands of documents per day.

What we deliver:

  • Custom PDF templates matching your brand design
  • Server-side generation with streaming download
  • Background job queue for large reports
  • S3 storage with lifecycle policies and download tracking
  • Multi-language and localized number/date formatting

Explore our web development services or contact us to discuss your document generation requirements.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.