Logo Manvith Reddy Dayam
How to Dynamically render PDFs in Nextjs with ReactPDF?

How to Dynamically render PDFs in Nextjs with ReactPDF?

May 30, 2025
Table of Contents

Storing fully rendered PDFs is slow, expensive, and hard to update. A better pattern is to store only JSON (the data and a reference to a template), then generate PDFs on-demand from React components. This guide shows the approach demonstrated in Kelvin Mai’s video https://www.youtube.com/watch?v=C3drtMt4g2E

What we need

  • A reusable PDF “template” built with @react-pdf/renderer (just React).

  • A Next.js dynamic route that converts JSON into a PDF response.

  • An optional in-browser preview via next/dynamic so it runs client-side only.

  • A storage pattern where you keep JSON + template key, not binary PDFs.

Benefits

  • Smaller storage and simpler lifecycle: keep JSON, regenerate PDFs anytime.

  • Easy design iteration: update the template once; new requests reflect it.

  • Consistent rendering: the same template powers server responses and previews.

  • Works on Vercel with the Node.js runtime; no custom infra needed.

Minimal Example

A generic PDF template (React)

This is a simple, reusable template. Replace the structure with whatever your documents need (invoices, reports, agreements, certificates, etc.).

genericPDF.tsx
import React from "react";
import {
  Document,
  Page,
  Text,
  View,
  StyleSheet,
  Font
} from "@react-pdf/renderer";
 
export type DocData = {
  title: string;
  subtitle?: string;
  sections?: Array<{
    heading?: string;
    bullets?: string[];
    body?: string;
  }>;
  footerNote?: string;
};
 
Font.register({
  family: "Inter",
  fonts: [
    {
      src: "https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fv.ttf"
    },
    {
      src: "https://fonts.gstatic.com/s/inter/v12/UcCM3FwrK3iLTYcBLXv1.ttf",
      fontWeight: 700
    }
  ]
});
 
const styles = StyleSheet.create({
  page: { padding: 28, fontFamily: "Inter", fontSize: 11, color: "#111827" },
  h1: { fontSize: 20, fontWeight: 700, marginBottom: 4 },
  h2: { fontSize: 13, color: "#6b7280", marginBottom: 12 },
  section: { marginTop: 12 },
  heading: { fontSize: 12, fontWeight: 700, marginBottom: 6 },
  bulletRow: { flexDirection: "row", marginBottom: 4 },
  bulletDot: { width: 10, textAlign: "center" },
  body: { lineHeight: 1.4 }
});
 
export function GenericPdf({ data }: { data: DocData }) {
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        <Text style={styles.h1}>{data.title}</Text>
        {data.subtitle ? <Text style={styles.h2}>{data.subtitle}</Text> : null}
 
        {data.sections?.map((s, i) => (
          <View key={i} style={styles.section} wrap={false}>
            {s.heading ? <Text style={styles.heading}>{s.heading}</Text> : null}
            {s.body ? <Text style={styles.body}>{s.body}</Text> : null}
            {s.bullets?.map((b, j) => (
              <View key={j} style={styles.bulletRow}>
                <Text style={styles.bulletDot}>•</Text>
                <Text>{b}</Text>
              </View>
            ))}
          </View>
        ))}
 
        {data.footerNote ? (
          <Text
            style={{ position: "absolute", bottom: 20, fontSize: 9, color: "#6b7280" }}
          >
            {data.footerNote}
          </Text>
        ) : null}
 
        <Text
          fixed
          render={({ pageNumber, totalPages }) =>
            `Page ${pageNumber} of ${totalPages}`
          }
          style={{ position: "absolute", right: 28, bottom: 20, fontSize: 9 }}
        />
      </Page>
    </Document>
  );
}
 

Data

data.ts
import type { DocData } from "@/pdf/GenericPdf";
 
const MOCKS: Record<string, { template: "generic"; payload: DocData }> = {
  example: {
    template: "generic",
    payload: {
      title: "Monthly Report",
      subtitle: "April 2025",
      sections: [
        {
          heading: "Summary",
          body:
            "Traffic grew 18% MoM. Conversion increased by 1.2pp with the " +
            "new onboarding flow."
        },
        {
          heading: "Highlights",
          bullets: [
            "Launched A/B test for pricing page",
            "Shipped self-serve exports",
            "Improved search relevance"
          ]
        }
      ],
      footerNote: "Confidential — for internal use only"
    }
  }
};
 
export async function getDocById(id: string) {
  return MOCKS[id];
}
 

Next dynamic api route

app/pdf/[id]/route.ts
import { NextRequest } from "next/server";
import { renderToBuffer } from "@react-pdf/renderer";
import { GenericPdf } from "@/pdf/GenericPdf";
import { getDocById } from "@/lib/data";
 
export const runtime = "nodejs";
 
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const record = await getDocById(params.id);
 
  if (!record) {
    return new Response("Not found", { status: 404 });
  }
 
  // Switch on template if you support multiple designs
  const Component = GenericPdf;
 
  const pdf = await renderToBuffer(<Component data={record.payload} />);
 
  return new Response(pdf, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `inline; filename="document-${params.id}.pdf"`,
      "Cache-Control": "public, max-age=0, s-maxage=3600"
    }
  });
}
 

Client-only preview with next/dynamic

Use the same template in a PDFViewer, but load it only on the client.

app/docs/[id]/ClientPreview.tsx
"use client";
 
import dynamic from "next/dynamic";
import { GenericPdf, type DocData } from "@/pdf/GenericPdf";
 
const PDFViewer = dynamic(
  () => import("@react-pdf/renderer").then((m) => m.PDFViewer),
  { ssr: false }
);
 
export function ClientPreview({ data }: { data: DocData }) {
  return (
    <div style={{ border: "1px solid #e5e7eb" }}>
      <PDFViewer width="100%" height="640">
        <GenericPdf data={data} />
      </PDFViewer>
    </div>
  );
}
 
app/docs/[id]/page.tsx
 
import Link from "next/link";
import { getDocById } from "@/lib/data";
import { ClientPreview } from "./ClientPreview";
 
export default async function Page({
  params
}: {
  params: { id: string };
}) {
  const record = await getDocById(params.id);
 
  if (!record) {
    return <div style={{ padding: 24 }}>Document not found.</div>;
  }
 
  return (
    <div style={{ padding: 24, fontFamily: "system-ui" }}>
      <h1>Preview: {params.id}</h1>
      <p style={{ margin: "12px 0" }}>
        <Link href={`/pdf/${params.id}`} target="_blank">
          Open PDF in a new tab
        </Link>
      </p>
      <ClientPreview data={record.payload} />
    </div>
  );
}
 

How it fits together

  • Your database stores JSON and a template key. No binary PDFs are stored.

  • A user hits /pdf/[id]. The route fetches JSON, chooses a template, and renders a PDF on the server with @react-pdf/renderer.

  • The same React component can be previewed in the browser via next/dynamic, avoiding SSR for the PDF viewer.

Why is this approach better?

  • Your storage layer is stable (JSON). Templates evolve independently.

  • You can log and audit exactly what data generated a PDF at any point.

  • You can add template variants without a data migration.

  • You can pre-render and cache specific PDFs if they’re requested often, but keep JSON as the source of truth.