Skip to content
rendoc
← Back to blog

Generate PDF Invoices in Next.js with rendoc

10 min readrendoc team
tutorialnextjsinvoices

This tutorial walks you through adding PDF invoice generation to a Next.js application using the rendoc SDK. By the end, you will have an API route that accepts invoice data, generates a PDF, and returns a download URL, plus a simple dashboard page to trigger it.

Total time: about 15 minutes. No infrastructure setup, no headless browsers, no Docker.

Prerequisites

  • A Next.js 14+ project (App Router).
  • Node.js 18 or later.
  • A rendoc account with an API key. The free tier includes 100 documents per month.

Step 1: Install the rendoc SDK

Install the SDK in your Next.js project:

npm install @rendoc/sdk

Add your API key to .env.local:

RENDOC_API_KEY=rnd_your_api_key_here

Step 2: Create the Invoice Template

Go to the rendoc dashboard and create a new template. Name it invoice. Paste this HTML into the template editor:

<div style="font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px;">
  <div style="display: flex; justify-content: space-between; margin-bottom: 40px;">
    <div>
      <h1 style="margin: 0; font-size: 28px; color: #111;">INVOICE</h1>
      <p style="color: #666; margin-top: 4px;">{{number}}</p>
    </div>
    <div style="text-align: right;">
      <p style="font-weight: 600; margin: 0;">{{company.name}}</p>
      <p style="color: #666; margin: 4px 0;">{{company.address}}</p>
      <p style="color: #666; margin: 0;">{{company.email}}</p>
    </div>
  </div>

  <div style="display: flex; justify-content: space-between; margin-bottom: 32px;">
    <div>
      <p style="font-size: 12px; text-transform: uppercase; color: #999; margin-bottom: 4px;">Bill To</p>
      <p style="font-weight: 600; margin: 0;">{{client.name}}</p>
      <p style="color: #666; margin: 4px 0;">{{client.address}}</p>
      <p style="color: #666; margin: 0;">{{client.email}}</p>
    </div>
    <div style="text-align: right;">
      <p style="margin: 0;"><strong>Date:</strong> {{date}}</p>
      <p style="margin: 4px 0;"><strong>Due:</strong> {{dueDate}}</p>
    </div>
  </div>

  <table style="width: 100%; border-collapse: collapse; margin-bottom: 32px;">
    <thead>
      <tr style="border-bottom: 2px solid #111;">
        <th style="text-align: left; padding: 8px 0;">Description</th>
        <th style="text-align: right; padding: 8px 0;">Qty</th>
        <th style="text-align: right; padding: 8px 0;">Price</th>
        <th style="text-align: right; padding: 8px 0;">Total</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr style="border-bottom: 1px solid #eee;">
        <td style="padding: 12px 0;">{{description}}</td>
        <td style="text-align: right; padding: 12px 0;">{{quantity}}</td>
        <td style="text-align: right; padding: 12px 0;">${{price}}</td>
        <td style="text-align: right; padding: 12px 0;">${{total}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>

  <div style="text-align: right;">
    <p style="margin: 4px 0; color: #666;">Subtotal: ${{subtotal}}</p>
    <p style="margin: 4px 0; color: #666;">Tax ({{taxRate}}%): ${{taxAmount}}</p>
    <p style="margin-top: 8px; font-size: 20px; font-weight: 700;">Total: ${{grandTotal}}</p>
  </div>
</div>

Use the preview feature in the dashboard to verify the layout with sample data before moving on. Note the template ID shown in the dashboard URL. It looks like tmpl_xxxxxxxx.

Step 3: Create the API Route

Create a new route handler at app/api/invoices/generate/route.ts:

import { Rendoc } from "@rendoc/sdk";

const rendoc = new Rendoc({
  apiKey: process.env.RENDOC_API_KEY!,
});

interface LineItem {
  description: string;
  quantity: number;
  price: number;
}

interface InvoiceData {
  number: string;
  date: string;
  dueDate: string;
  company: {
    name: string;
    address: string;
    email: string;
  };
  client: {
    name: string;
    address: string;
    email: string;
  };
  items: LineItem[];
  taxRate: number;
}

export async function POST(request: Request) {
  const data: InvoiceData = await request.json();

  // Validate required fields
  if (!data.number || !data.items?.length) {
    return Response.json(
      { error: "Missing required fields: number, items" },
      { status: 400 }
    );
  }

  // Calculate line item totals
  const items = data.items.map((item) => ({
    ...item,
    total: (item.quantity * item.price).toFixed(2),
  }));

  const subtotal = data.items.reduce(
    (sum, item) => sum + item.quantity * item.price,
    0
  );
  const taxRate = data.taxRate ?? 0;
  const taxAmount = subtotal * (taxRate / 100);
  const grandTotal = subtotal + taxAmount;

  try {
    const doc = await rendoc.documents.generate({
      templateId: "tmpl_your_invoice_template",
      data: {
        ...data,
        items,
        subtotal: subtotal.toFixed(2),
        taxAmount: taxAmount.toFixed(2),
        grandTotal: grandTotal.toFixed(2),
      },
      paperSize: "A4",
    });

    return Response.json({
      success: true,
      documentId: doc.id,
      downloadUrl: doc.downloadUrl,
      pageCount: doc.pageCount,
    });
  } catch (err) {
    const message =
      err instanceof Error ? err.message : "Unknown error";
    return Response.json(
      { error: "PDF generation failed", details: message },
      { status: 502 }
    );
  }
}

Replace tmpl_your_invoice_template with your actual template ID from the dashboard.

Step 4: Handle the Async Response

The rendoc API returns a document object with a downloadUrl where the PDF can be fetched. For most use cases, you return this URL to the client:

// Response shape from the API route
{
  "success": true,
  "documentId": "doc_abc123",
  "downloadUrl": "https://rendoc.dev/d/doc_abc123.pdf",
  "pageCount": 1
}

If you need to proxy the PDF (to avoid exposing the rendoc URL or to add authentication), you can fetch it server-side and stream it back:

// Alternative: proxy the PDF through your API
export async function GET(request: Request) {
  const url = new URL(request.url);
  const documentId = url.searchParams.get("id");

  if (!documentId) {
    return Response.json({ error: "Missing document ID" }, { status: 400 });
  }

  const doc = await rendoc.documents.get(documentId);
  const pdfResponse = await fetch(doc.downloadUrl);
  const pdfBuffer = await pdfResponse.arrayBuffer();

  return new Response(pdfBuffer, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `attachment; filename="invoice-${documentId}.pdf"`,
    },
  });
}

Step 5: Build the Dashboard Page

Create a simple page where users can generate invoices. This example uses a client component with a form:

"use client";

import { useState } from "react";

export default function InvoiceDashboard() {
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<{
    downloadUrl: string;
    documentId: string;
  } | null>(null);

  async function handleGenerate() {
    setLoading(true);
    setResult(null);

    const response = await fetch("/api/invoices/generate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        number: "INV-" + Date.now().toString().slice(-6),
        date: new Date().toISOString().split("T")[0],
        dueDate: new Date(Date.now() + 30 * 86400000)
          .toISOString()
          .split("T")[0],
        company: {
          name: "Your Company",
          address: "123 Main St, San Francisco, CA",
          email: "billing@yourcompany.com",
        },
        client: {
          name: "Acme Corp",
          address: "456 Oak Ave, Austin, TX",
          email: "accounts@acme.com",
        },
        items: [
          { description: "Web Development", quantity: 40, price: 150 },
          { description: "Design Review", quantity: 8, price: 120 },
          { description: "Hosting", quantity: 1, price: 200 },
        ],
        taxRate: 8.5,
      }),
    });

    const data = await response.json();

    if (data.success) {
      setResult({
        downloadUrl: data.downloadUrl,
        documentId: data.documentId,
      });
    }

    setLoading(false);
  }

  return (
    <div style={{ maxWidth: 600, margin: "0 auto", padding: 40 }}>
      <h1>Invoice Generator</h1>
      <button
        onClick={handleGenerate}
        disabled={loading}
        style={{
          padding: "12px 24px",
          fontSize: 16,
          cursor: loading ? "wait" : "pointer",
        }}
      >
        {loading ? "Generating..." : "Generate Sample Invoice"}
      </button>

      {result && (
        <div style={{ marginTop: 24 }}>
          <p>Invoice generated successfully.</p>
          <a
            href={result.downloadUrl}
            target="_blank"
            rel="noopener noreferrer"
          >
            Download PDF
          </a>
        </div>
      )}
    </div>
  );
}

In a real application, you would replace the hardcoded data with form inputs or data fetched from your database.

Full Working Example

Here is the complete file structure for the invoice feature:

app/
  api/
    invoices/
      generate/
        route.ts          # POST handler: generates PDF
  dashboard/
    invoices/
      page.tsx            # Client component: invoice form
.env.local                # RENDOC_API_KEY=rnd_...
package.json              # @rendoc/sdk in dependencies

The complete API route with all the code from steps 3 and 4 is about 60 lines of TypeScript. The dashboard component is another 70 lines. That is the entire PDF invoice feature: 130 lines of code, no infrastructure.

Alternative: Using the Next.js Helper

If you generate PDFs from multiple routes, the @rendoc/sdk/next helper simplifies the pattern:

// app/api/invoices/generate/route.ts
import { createPdfHandler } from "@rendoc/sdk/next";

export const POST = createPdfHandler({
  templateId: "tmpl_your_invoice_template",
  defaultOptions: {
    paper_size: "A4",
  },
});

The helper creates a route handler that reads the API key from RENDOC_API_KEY, parses the request body, merges your default options, and returns the document response. One line to define a PDF endpoint.

Going Further

Batch Generation

For monthly billing runs where you need to generate hundreds of invoices, process them in parallel:

const invoices = await db.getUnpaidInvoices();

const results = await Promise.all(
  invoices.map((invoice) =>
    rendoc.documents.generate({
      templateId: "tmpl_invoice",
      data: formatInvoiceData(invoice),
      paperSize: "A4",
    })
  )
);

// results is an array of Document objects with downloadUrls

Email Integration

After generating the PDF, fetch it and attach it to an email:

const doc = await rendoc.documents.generate({
  templateId: "tmpl_invoice",
  data: invoiceData,
});

const pdfResponse = await fetch(doc.downloadUrl);
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());

await sendEmail({
  to: customer.email,
  subject: `Invoice ${invoiceData.number}`,
  attachments: [
    {
      filename: `invoice-${invoiceData.number}.pdf`,
      content: pdfBuffer,
    },
  ],
});

Storing in S3 or R2

For long-term storage, download the generated PDF and upload it to your storage provider:

const doc = await rendoc.documents.generate({
  templateId: "tmpl_invoice",
  data: invoiceData,
});

const pdfResponse = await fetch(doc.downloadUrl);
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());

await s3.putObject({
  Bucket: "invoices",
  Key: `${invoiceData.number}.pdf`,
  Body: pdfBuffer,
  ContentType: "application/pdf",
});

Summary

Adding PDF invoice generation to a Next.js app with rendoc takes about 15 minutes and 130 lines of code. You get a type-safe SDK, a visual template editor, and no infrastructure to manage.

The workflow is: design your template in the rendoc dashboard, create an API route that calls the SDK, and wire up your frontend. When the invoice design needs to change, update the template in the dashboard. No code changes, no redeployments.

Generate PDFs without the headaches

rendoc turns your templates into pixel-perfect PDFs with a single API call. Free tier included.