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.).
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
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
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.
"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>
);
}
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.
