Skip to content
Back to blog
nextjs tutorial api developer guide

How to Generate PDFs from JSON in Next.js 15 (App Router & Server Actions)

pdfs.build Team

Generating dynamic PDF documents in Next.js applications has historically been a painful engineering challenge—especially when deploying to serverless environments like Vercel, AWS Lambda, or Cloudflare Workers.

If you have tried using headless Chrome (puppeteer, playwright, or @sparticuz/chromium) inside a Next.js App Router route handler or Server Action, you have likely run into:

  1. Serverless Bundle Size Limits: Chromium binaries easily exceed the 50MB direct zip limit and bloat deployments.
  2. Punishing Cold Starts: Launching a headless browser inside a fresh Lambda container adds 2–4 seconds of latency before rendering even begins.
  3. Execution Timeouts: Long report generation jobs hit serverless function execution limits.
  4. CSS Print Quirks: CSS @media print rules, orphan headers, and repeating table headers behave unpredictably across Chromium updates.

In this guide, we will demonstrate how to generate publication-grade PDFs from JSON data in Next.js 15, with a median render of about 220ms, using modern Route Handlers, Server Actions, and the pdfs.build Typst rendering API.


Architectural Pattern: Separation of Template & Data

Instead of rendering React components to HTML and running headless browser print drivers inside your serverless functions, modern architectures decouple the document template from the Next.js runtime:

[ Next.js App Router ]
   │
   │  1. Queries DB (Prisma / Drizzle)
   │  2. Assembles JSON payload
   │  3. POST /v2/.../render
   ▼
[ pdfs.build Engine (Typst) ] ──▶ Returns application/pdf binary (~220ms median)
   │
   ▼
[ Client Browser / S3 / Email ]

Implementation 1: Next.js 15 Route Handler (Direct PDF Download)

A common use case is providing a direct download link (e.g., <a href="/api/invoices/inv_123/pdf">Download Invoice</a>).

Here is how to implement this using a Next.js 15 App Router route handler:

// app/api/invoices/[id]/pdf/route.ts
import { NextRequest, NextResponse } from "next/server";

interface RouteContext {
  params: Promise<{ id: string }>;
}

export async function GET(request: NextRequest, { params }: RouteContext) {
  const { id } = await params;

  // 1. Fetch invoice data from your database (e.g., Prisma or Drizzle)
  const invoice = await fetchInvoiceFromDatabase(id);
  if (!invoice) {
    return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
  }

  // 2. Call the pdfs.build REST API
  const renderResponse = await fetch(
    `https://api.pdfs.build/v2/organizations/${process.env.PDFS_BUILD_ORG_ID}/templates/invoice-standard/render`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.PDFS_BUILD_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        data: {
          invoice_number: invoice.number,
          date: invoice.createdAt.toISOString().split("T")[0],
          customer: {
            name: invoice.customerName,
            email: invoice.customerEmail,
          },
          items: invoice.items.map((item) => ({
            description: item.name,
            quantity: item.qty,
            unit_price: item.price,
          })),
          subtotal: invoice.subtotal,
          tax: invoice.tax,
          total: invoice.total,
        },
      }),
    }
  );

  if (!renderResponse.ok) {
    const errorJson = await renderResponse.json();
    console.error("PDF render failed:", errorJson);
    return NextResponse.json({ error: "Failed to render PDF" }, { status: 500 });
  }

  // 3. Stream the raw binary PDF back to the browser
  const pdfBuffer = await renderResponse.arrayBuffer();

  return new NextResponse(pdfBuffer, {
    status: 200,
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `inline; filename="invoice-${invoice.number}.pdf"`,
      "Cache-Control": "private, max-age=3600",
    },
  });
}

The Typst engine compiles a one-page invoice natively in about 40ms, and there is no browser to boot. The whole API call has a median of about 220ms.


Implementation 2: Next.js 15 Server Action (Send PDF via Email)

When a user clicks “Email me my report”, you often want to generate the PDF on the server and attach it to an email (e.g., via Resend or AWS SES) without client downloads:

// app/actions/send-report.ts
"use server";

import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendReportAction(reportId: string, recipientEmail: string) {
  // 1. Gather dynamic report metrics
  const reportData = await getMonthlyMetrics(reportId);

  // 2. Request PDF binary from pdfs.build API
  const pdfResponse = await fetch(
    `https://api.pdfs.build/v2/organizations/${process.env.PDFS_BUILD_ORG_ID}/templates/monthly-report/render`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.PDFS_BUILD_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        data: reportData,
      }),
    }
  );

  if (!pdfResponse.ok) {
    throw new Error("Could not render report PDF");
  }

  const pdfArrayBuffer = await pdfResponse.arrayBuffer();
  const pdfBuffer = Buffer.from(pdfArrayBuffer);

  // 3. Dispatch email with PDF attachment
  await resend.emails.send({
    from: "[email protected]",
    to: recipientEmail,
    subject: `Your Monthly Report (${reportData.month})`,
    text: "Please find your attached monthly performance summary.",
    attachments: [
      {
        filename: `report-${reportData.month}.pdf`,
        content: pdfBuffer,
      },
    ],
  });

  return { success: true };
}

Enforcing Type Safety with Schema Contracts

One of the largest hidden risks in dynamic PDF generation is silent rendering failures—such as passing null where a string is expected, causing text overlap or missing totals on production documents.

Every template in pdfs.build is backed by a JSON Schema. You can generate matching TypeScript types directly or validate payloads using Zod before calling the API:

import { z } from "zod";

const InvoiceSchema = z.object({
  invoice_number: z.string(),
  date: z.string(),
  customer: z.object({
    name: z.string(),
    email: z.string().email(),
  }),
  items: z.array(
    z.object({
      description: z.string(),
      quantity: z.number().int().positive(),
      unit_price: z.number().positive(),
    })
  ),
  total: z.number(),
});

type InvoiceData = z.infer<typeof InvoiceSchema>;

If a required field is missing, the render request is rejected with an HTTP 422 Unprocessable Entity containing the exact JSON field path, ensuring broken PDFs never reach your users.


Conclusion

By offloading document compilation to a dedicated PDF generation API, you keep your Next.js application lightweight, avoid serverless memory limits, and render documents in about 220ms at the median.

Back to blog