DEV Community

Ango Jeffrey
Ango Jeffrey

Posted on

Launching My First NPM Package: How I Built a Headless-Browser-Free JSX PDF Engine

First off, I didn't plan to build another PDF generation library. I completely assumed that the existing tools in the ecosystem were already good enough.

Boy, was I surprised.

At work, I was handed what I assumed would be a simple task: building a transaction receipt layout. The rendering had to happen entirely on the backend, because the generated PDFs needed to be consistently consumed across our mobile app, web app, and admin dashboard. Building it in one centralized place made perfect architectural sense.

I started the task completely unaware of the massive infrastructure and developer experience frustration I was heading into. This is how my rabbit hole journey started:

Phase 1: The Imperative JSON Nightmare (pdfmake)

I started with lower-level tools like pdfmake. While it's fast and lightweight, trying to build a polished, modern, responsive layout felt incredibly restrictive. The styling options were limited, the layout logic wasn't intuitive, and dealing with raw Base64 strings directly for images was incredibly clunky. I found myself stacking hack upon hack just to get a receipt design looking remotely close to the original Figma mockup.

Phase 2: The "Works on My Machine" Trap (Puppeteer)

Frustrated with the lack of design flexibility, I pivoted to Puppeteer. Suddenly, the Developer Experience (DX) felt amazing! I could use HTML and CSS, and it rendered perfectly on my local machine.

Then came deployment.

As soon as it hit the cloud environment, I got slammed with the classic headless-browser curse: it completely broke inside the Docker container. If you've ever spent hours chasing down missing Debian dependencies, managing zombie browser processes, or watching serverless cold starts spike, you know how painful this is. Puppeteer gave me web-like design freedom but introduced a DevOps nightmare.

The Realization

Why should we have to choose between a rigid layout workflow or a bloated deployment pipeline? Why couldn't we have the incredible DX of modern component-driven styling (like JSX and Flexbox) without the heavy, fragile infrastructure of a browser subsystem?

I didn't want to compromise on speed or DX.

To solve this, I ended up building Nebula PDF Engine (nebula-pdf-engine)—a server-side generator that brings a component-driven JSX and Flexbox styling workflow to the backend. It runs completely free of headless browsers, making it 100× smaller than Puppeteer and fully optimized for serverless and edge runtimes.


Code Showdown: pdfmake vs. Nebula

To show you exactly what I mean by "hacking layouts," let’s look at how you would build a styled receipt header containing a modern rounded company logo on the left, a metadata text block, and a pricing row.

The Old Way: Pre-fetching, Base64 Conversions, and Rigid JSON (pdfmake)

With traditional imperative tools, you can't just pass an image URL or a raw asset stream directly into your layout. You are forced to handle the asynchronous network request yourself, convert the asset into a massive Base64 string, and inject it into a deeply nested JSON structure.

Worse yet, pdfmake does not support borderRadius. If you want a rounded circular logo, you have to either pre-process the image binary using an external library like sharp beforehand, or write a complex inline SVG vector mask to clip the canvas manually. There is no simple styling property to save you:

import fs from 'fs';

// Step 1: You have to manually handle file/buffer conversion yourself
const logoBase64 = fs.readFileSync('./assets/logo.png', { encoding: 'base64' });
const logoDataUrl = `data:image/png;base64,${logoBase64}`;

// Step 2: Bind it into a rigid, non-intuitive layout array
const docDefinition = {
  content: [
    {
      columns: [
        {
          image: logoDataUrl, // Binds a massive string directly into your configuration
          width: 50
          // Note: No 'borderRadius' or clipping support exists natively in pdfmake styling!
        },
        {
          text: 'Subscription Fee',
          style: 'labelStyle'
        },
        {
          text: '$29.00',
          alignment: 'right',
          style: 'priceStyle'
        }
      ]
    }
  ],
  styles: {
    labelStyle: { fontSize: 12, color: '#333333', margin: [0, 10, 0, 0] },
    priceStyle: { fontSize: 12, bold: true, color: '#000000', margin: [0, 10, 0, 0] }
  }
};
Enter fullscreen mode Exit fullscreen mode

The Nebula Way: Declarative Component UI

With Nebula, image handling, Flexbox alignments, and standard CSS styling rules—including borderRadius—are treated as first-class citizens. The engine supports a lightweight Preact JSX runtime under the hood. It accepts file paths, buffers, or remote URLs out of the box, abstracting away the boilerplate so your code stays completely declarative and intuitive:

import { PdfEngine, Page, View, Text, Image } from 'nebula-pdf-engine';

const ReceiptHeader = ({ logoUrl, label, price }) => (
  <Page size="A4" padding={40}>
    {/* Clean, component-driven Flexbox that just makes sense */}
    <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
      {/* Dynamic remote source resolution and effortless border-radius clipping */}
      <Image src={logoUrl} style={{ width: 50, height: 50, borderRadius: '50%' }} />

      <View style={{ flexDirection: 'row', gap: 10 }}>
        <Text style={{ fontSize: 12, color: '#333333' }}>{label}</Text>
        <Text style={{ fontSize: 12, fontWeight: 'bold', color: '#000000' }}>{price}</Text>
      </View>
    </View>
  </Page>
);
Enter fullscreen mode Exit fullscreen mode

How it Works: The Under-the-Hood Layout Pipeline

To achieve this without spinning up a 150MB headless Chromium instance, Nebula orchestrates a highly efficient pipeline powered by Satori, Resvg (Rust), and pdf-lib.

Instead of treating the document as a single massive canvas, Nebula features a recursive, 4-stage Advanced Layout Engine:

  • Measurement Pass: Every child element is pre-rendered at the target layout width to calculate its exact content height dynamically.
  • Bin Packing: Elements are distributed logically into separate pages according to your document's contentHeight.
  • Table Pipeline: Tables trigger a specialized, row-by-row pagination loop with automatic column resolution.
  • Atomic vs. Splittable Distribution: The engine categorizes components natively. Elements like Images, Boxes, and individual Table Rows are flagged as Atomic (they will never be sliced in half across pages). Text components are flagged as Splittable, meaning they will gracefully break at a safe word boundary and overflow onto the next page.

Architectural Choices & Key Trade-offs

When designing Nebula, these were the non-negotiable guiding principles:

  • Zero Headless-Browser Overhead: Bypassing Chromium gives us sub-second execution speeds, tiny memory footprints, and absolute peace of mind when deploying to Vercel, AWS Lambda, or lightweight Docker containers.
  • Retina Asset Scaling via Sharp: To keep generated documents crisp without creating giant file sizes, remote images and graphics are dynamically downsampled and processed via sharp according to a customizable devicePixelRatio.
  • Flexible Pagination: You get both Automatic Overflow (great for variable lists) and Manual Pagination (using multiple <Page> wrappers for separate layouts like a dedicated Cover Page).

Lessons Learned Launching My First Library

Shipping version 1.0.0 (and subsequent patches) taught me an incredible amount about open-source maintenance and developer experience (DX). Writing core compilation code is only half the battle; the other half is structuring intuitive APIs and building an ecosystem that fits effortlessly into a developer's existing tools.

Nebula PDF Engine is completely open-source and available right now. If you're tired of battling headless browser memory leaks or fighting messy imperative document arrays, I'd love for you to give it a spin!

  • NPM: npm install nebula-pdf-engine
  • GitHub: Give it a star or contribute here!

What about you?

Have you struggled with PDF generation on serverless environments or Docker setups? Let’s chat down in the comments!

Top comments (0)