Ivoice Generator

How I Built Real Vector PDF Invoices in My SaaS — React Frontend + NestJS Backend

No canvas. No screenshots. No html2canvas. Just clean, selectable, zoomable, printable PDFs — exactly like Tally or Excel generates them.


I'm building a full-stack event management SaaS called HostWithMe where vendors can generate GST-compliant Tax Invoices for their clients. The invoice had to feel professional — the kind a CA would accept, not a screenshot stapled to an email.

This post walks through the entire implementation: the thinking behind the approach, the React PDF layout, the NestJS API, the logo embedding fix, and every gotcha I hit along the way.


The Problem with the "Easy" Approaches

When most developers need to generate a PDF from a React page, they reach for one of these:

  • window.print() with print CSS

  • html2canvasjsPDF

  • Puppeteer to screenshot the page

These all have the same fatal flaw: they generate image-based PDFs. The text inside is not real text — it's pixels baked into an image layer. You can't:

  • Select or copy text

  • Search inside the PDF

  • Zoom in without it blurring

  • Have screen readers parse it

That's not a PDF. That's a fancy screenshot.

What I needed was what Tally, QuickBooks, and Excel produce — a vector PDF where every character is a real typographic glyph, every line is a real vector path, and the file is a true document.


The Right Tool: @react-pdf/renderer

@react-pdf/rendererarrow-up-right is a React renderer that targets PDFKit instead of the DOM. You write JSX, but instead of HTML elements it has its own primitives: <Document>, <Page>, <View>, <Text>, <Image>. It compiles your component tree into a real .pdf file with embedded fonts and vector graphics.

The key mental shift: this is not a styled HTML page. You're writing a layout engine for print. No Tailwind, no CSS classes — everything is inline StyleSheet objects, similar to React Native.


Architecture Overview

Here's how the full system is wired together:

Two separate concerns:

  1. The PDF Document component — pure layout, no side effects

  2. The API layer — data fetching, image resolution, streaming

Let me walk through both.


Part 1: The React PDF Document Component

The Layout Philosophy

I needed to match this exact invoice structure:

File: InvoicePDFDocument.tsx

Key Patterns in @react-pdf/renderer

1. <View> is your <div>

Everything is a View. Flex is the only layout model — and it defaults to flexDirection: "column", unlike CSS which defaults to row.

2. Tables are just nested Views with borders

There's no <table> element. You fake it with borders:

3. Bold text requires a different fontFamily

There's no fontWeight: "bold" that just works. You must switch the fontFamily:

Built-in fonts available: Helvetica, Helvetica-Bold, Helvetica-Oblique, Helvetica-BoldOblique, and the same variants for Courier and Times-Roman.

4. The <Image> component for logos

More on why logoBase64 and not a URL — see Part 3.

5. The Grand Total row — dark background

The HSN Summary Block

This is an important GST requirement — a control block at the bottom grouping tax amounts by HSN/SAC code. I built it by reducing the line items:

Amount in Words — Indian Number System

Standard number-to-words doesn't handle Indian numbering (Lakhs, Crores). I wrote a custom converter:


Part 2: The NestJS Backend

In my stack, the PDF is generated server-side via a NestJS API. This is the recommended approach because:

  • renderToBuffer() runs in Node — no browser needed

  • You can pre-process images, fetch data, and validate before rendering

  • You can also store the PDF to S3 / send via email in the same flow

Controller

Service

Note for Next.js users: If you're not using NestJS, the equivalent is an App Router API route (app/api/invoice/pdf/route.ts) with renderToBuffer() called inside a POST handler. The logic is identical — only the HTTP wrapper differs.


Part 3: The Logo Problem (and Why It's Subtle)

This is the part that caught me off guard and took me a while to debug.

What I tried first

The logo showed perfectly in the browser preview (using a regular <img> tag). But in the generated PDF — blank. Just empty space where the logo should be.

Why it fails

@react-pdf/renderer runs on the Node.js server, not in the browser. When it tries to fetch a remote URL like a Cloudinary image:

  1. CORS: Cloudinary and other CDNs set CORS headers for browser requests. Node's fetch is not a browser — it sends different headers and can get blocked or silently fail

  2. No browser cache: The image isn't pre-loaded like it would be in a browser

  3. Timing: @react-pdf/renderer doesn't always await remote image fetches reliably

The fix: pre-fetch and convert to base64

The solution is to fetch the image yourself, on the server, before calling renderToBuffer. Then pass the image as a base64 data URI — no network call needed during rendering.

Then in the PDF component:

This is the same reason tools like Puppeteer and wkhtmltopdf need --allow-local-file-access or explicit network waiting — rendering engines don't always behave like a real browser loading a page.


Part 4: The React Frontend Integration

On the frontend, two things exist side by side:

  1. The HTML preview — your existing React component, rendered normally in the browser using Tailwind/CSS — this is what the user sees on screen

  2. The PDF download — calls the backend API, gets a binary blob, triggers a download

They're completely independent. The HTML preview doesn't need to match the PDF layout pixel-perfectly — they're built with different tools for different purposes.

The download utility

The button in the component

Preview in new tab instead of downloading:


Complete Data Flow — End to End


Gotchas Summary

Problem
Why it happens
Fix

Logo missing in PDF

Remote URL fetch fails in Node / CORS

Pre-fetch to base64 before renderToBuffer

fontWeight: "bold" not working

@react-pdf has limited CSS support

Use fontFamily: "Helvetica-Bold"

display: flex not needed

@react-pdf defaults to column flex

Use flexDirection: "row" explicitly

rupee symbol not rendering

Built-in fonts don't have all Unicode

Register a custom font (Noto Sans)

PDF cuts off mid-page

Fixed height containers overflow

Use minHeight, avoid fixed heights on Page

Next.js SSR error with @react-pdf

Library uses Node APIs

Only import in API routes or "use server" files

Canvas error in Next.js

Optional peer dependency

Add canvas: false to webpack aliases

Fixing the Rupee symbol (if needed)

If your renders as a box or empty:

Next.js webpack config fix


What I'd Do Differently

For scale: Move PDF generation to a dedicated microservice or a background job queue (BullMQ). Generating PDFs is CPU-bound and you don't want it blocking your main NestJS event loop under load.

For logos: Store a pre-processed base64 version of the logo in the vendor document at upload time — skip the runtime fetch entirely.

For complex layouts: Consider pdfmakearrow-up-right if you need more control over page breaks, multi-page tables, or watermarks. It has a lower-level API but is more predictable for complex documents.

For email delivery: After renderToBuffer, you can directly attach the buffer to a Nodemailer email:


Final Thoughts

The moment I opened the generated PDF and could select the invoice number, copy the vendor address, and Ctrl+F search for the client name — that was the payoff. It felt like a real document, not a hack.

The key lessons:

  • @react-pdf/renderer is the right tool for vector PDFs in a JS/TS stack. It's mature, actively maintained, and the output quality is excellent.

  • Never pass remote image URLs to the renderer. Always pre-fetch and convert to base64 server-side.

  • The HTML preview and the PDF are separate concerns. Don't try to share the same component for both — they're built for completely different rendering engines.

  • Think print-first. Minimal color, clean borders, readable at 100% zoom and at A4 print size.

The full source for InvoicePDFDocument.tsx, the NestJS service, and the client utility function are available in the project repo.


Building HostWithMe — an event management platform for Indian vendors. Follow along for more posts on GST compliance, full-stack architecture, and shipping fast.


Tags: React NestJS PDF TypeScript GST Invoice @react-pdf/renderer Full Stack

Last updated