How do you generate PDFs in Webflow Cloud without Puppeteer or external services?

Learn how to build a document builder app in Webflow Cloud using pdf-lib and a Next.js route handler.

How do you generate PDFs in Webflow Cloud without Puppeteer or external services?

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Turning structured data into a clean PDF is a recurring need on Webflow sites, and you can handle the whole job inside a single Webflow Cloud app.

Turning structured data into a clean PDF is a recurring need on Webflow sites, from invoices to contracts to certificates. The common approach relies on an external document service like DocuSign and an automation step to connect it, which works but adds another tool and a recurring cost to the stack.

You do not need any of that in most cases. Webflow Cloud runs a full Next.js app, so a PDF generator is just a Route Handler that takes form data and returns a file.

The piece that trips people up is the library. Most PDF tutorials use Puppeteer, which renders HTML in a headless Chrome instance, but Puppeteer does not run on Webflow Cloud because the Workers runtime lacks a Chromium binary.

The library that does run is pdf-lib: pure JavaScript, no native dependencies, and it works in any JavaScript runtime, including Workers. The constraint it imposes, drawing the document programmatically rather than from HTML, turns out to be a feature, since the output is deterministic and fast. Here is how to build it.

What do you need to build a document builder app in Webflow Cloud?

You need a Webflow Cloud Next.js project and the pdf-lib package. No external services or API keys are required for the core build. Everything runs inside the one app.

Check out these requirements before you proceed:

  • A Webflow Cloud project running a Next.js app, deployed or in local dev
  • The pdf-lib package for PDF generation (pure JS, edge-compatible)
  • Node.js 22.13.0 or higher locally, the floor set by Webflow CLI 2.0
  • Optional: Webflow Cloud Object Storage if you want to save generated documents rather than only stream them to the user

Once these are in place, a working document builder takes about an hour.

6 steps to build a document builder app in Webflow Cloud

The architecture is straightforward. A Client Component renders the form where the user enters document data. On submit, it POSTs that data to a Route Handler. The Route Handler uses pdf-lib to draw the document and returns the PDF bytes with a Content-Type: application/pdf header.

The browser either previews the PDF or downloads it. If you want a record of generated documents, an optional step writes the PDF to Webflow Cloud Object Storage and stores a reference in the Key Value Store.

I keep the drawing logic in a separate module from the Route Handler. The handler validates input and handles the HTTP layer; the module knows how to draw an invoice. That separation has saved me every time a client asks for a second document type, because the second type is a new drawing module rather than a new endpoint.

1. Install pdf-lib in your Webflow Cloud project

From the root of your Webflow Cloud Next.js project, install the library:

npm install pdf-lib

If your documents need a custom font (anything beyond the 14 standard PDF fonts like Helvetica and Times Roman), also install fontkit:

npm install @pdf-lib/fontkit

pdf-lib is written in TypeScript and compiled to pure JavaScript with no native dependencies. This is the property that matters for Webflow Cloud: it runs on the Workers runtime, where Puppeteer and other Chromium-based generators fail. No build configuration, no binary, no environment variables.

2. Build the PDF drawing module for your document builder

Create a module that takes structured data and returns PDF bytes. This example draws an invoice, but the same pattern applies to any document type. Keep this separate from the Route Handler so each document type is its own module.

Here’s the snippet:

// lib/documents/invoice.ts
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'

export type InvoiceData = {
  invoiceNumber: string
  date: string
  billTo: { name: string; email: string; address: string }
  lineItems: { description: string; quantity: number; unitPrice: number }[]
  notes?: string
}

export async function generateInvoicePdf(data: InvoiceData): Promise<Uint8Array> {
  const pdfDoc = await PDFDocument.create()
  let page = pdfDoc.addPage([595, 842]) // A4 in points
  const { width, height } = page.getSize()

  const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
  const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)

  const margin = 50
  let cursorY = height - margin

  // Header
  page.drawText('INVOICE', {
    x: margin,
    y: cursorY,
    size: 28,
    font: helveticaBold,
    color: rgb(0.1, 0.1, 0.1),
  })

  page.drawText(`#${data.invoiceNumber}`, {
    x: width - margin - 120,
    y: cursorY,
    size: 14,
    font: helvetica,
    color: rgb(0.4, 0.4, 0.4),
  })

  cursorY -= 50

  // Bill-to block
  page.drawText('Bill To:', { x: margin, y: cursorY, size: 11, font: helveticaBold })
  cursorY -= 16
  page.drawText(data.billTo.name, { x: margin, y: cursorY, size: 11, font: helvetica })
  cursorY -= 14
  page.drawText(data.billTo.email, { x: margin, y: cursorY, size: 11, font: helvetica })
  cursorY -= 14
  page.drawText(data.billTo.address, { x: margin, y: cursorY, size: 11, font: helvetica })

  page.drawText(`Date: ${data.date}`, {
    x: width - margin - 120,
    y: cursorY + 28,
    size: 11,
    font: helvetica,
  })

  cursorY -= 50

  // Line items table header
  page.drawText('Description', { x: margin, y: cursorY, size: 10, font: helveticaBold })
  page.drawText('Qty', { x: 360, y: cursorY, size: 10, font: helveticaBold })
  page.drawText('Unit', { x: 420, y: cursorY, size: 10, font: helveticaBold })
  page.drawText('Total', { x: 490, y: cursorY, size: 10, font: helveticaBold })

  cursorY -= 6
  page.drawLine({
    start: { x: margin, y: cursorY },
    end: { x: width - margin, y: cursorY },
    thickness: 1,
    color: rgb(0.8, 0.8, 0.8),
  })
  cursorY -= 18

  // Line items
  let grandTotal = 0
  for (const item of data.lineItems) {
    const lineTotal = item.quantity * item.unitPrice
    grandTotal += lineTotal

    page.drawText(item.description, { x: margin, y: cursorY, size: 10, font: helvetica })
    page.drawText(String(item.quantity), { x: 360, y: cursorY, size: 10, font: helvetica })
    page.drawText(`$${item.unitPrice.toFixed(2)}`, { x: 420, y: cursorY, size: 10, font: helvetica })
    page.drawText(`$${lineTotal.toFixed(2)}`, { x: 490, y: cursorY, size: 10, font: helvetica })
    cursorY -= 18
  }

  // Grand total
  cursorY -= 10
  page.drawText('Total:', { x: 420, y: cursorY, size: 12, font: helveticaBold })
  page.drawText(`$${grandTotal.toFixed(2)}`, { x: 490, y: cursorY, size: 12, font: helveticaBold })

  // Optional notes
  if (data.notes) {
    cursorY -= 50
    page.drawText('Notes:', { x: margin, y: cursorY, size: 10, font: helveticaBold })
    cursorY -= 14
    page.drawText(data.notes, { x: margin, y: cursorY, size: 10, font: helvetica })
  }

  return await pdfDoc.save()
}

pdf-lib draws with the origin at the bottom-left corner, so y-coordinates count up from the bottom. I track a cursorY variable, which I decrement as I scroll down the page. This handles variable-length content (any number of line items) without hardcoding positions.

3. Build the document generation Route Handler in Webflow Cloud

The Route Handler receives the form data, validates it, calls the drawing module, and returns the PDF. Do not add export const runtime = 'edge'. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js Edge runtime, so the directive breaks the build. Route Handlers run on the Node.js runtime on top of Workers.

Here’s what it looks like:

// app/api/documents/invoice/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { generateInvoicePdf, type InvoiceData } from '@/lib/documents/invoice'

export async function POST(request: NextRequest) {
  let data: InvoiceData

  try {
    data = await request.json() as InvoiceData
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
  }

  // Validate required fields before drawing anything.
  if (!data.invoiceNumber || !data.billTo?.name || !data.lineItems?.length) {
    return NextResponse.json(
      { error: 'Missing required fields: invoiceNumber, billTo.name, lineItems' },
      { status: 422 }
    )
  }

  const pdfBytes = await generateInvoicePdf(data)

  // Return the PDF as a downloadable file.
  return new NextResponse(pdfBytes as BodyInit, {
    status: 200,
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${data.invoiceNumber}.pdf"`,
    },
  })
}

Set Content-Disposition to attachment to trigger a download, or inline if you want the PDF to open in the browser's PDF viewer instead. The pdfBytes from pdf-lib is a Uint8Array, which is a valid response body on the Workers runtime.

4. Build the document builder form UI

The Client Component renders the form and handles submission.

On submit, it POSTs the form data and triggers a download from the returned PDF blob:

// app/document-builder/InvoiceBuilder.tsx
'use client'

import { useState } from 'react'

type LineItem = { description: string; quantity: number; unitPrice: number }

export default function InvoiceBuilder() {
  const [invoiceNumber, setInvoiceNumber] = useState('')
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [address, setAddress] = useState('')
  const [lineItems, setLineItems] = useState<LineItem[]>([
    { description: '', quantity: 1, unitPrice: 0 },
  ])
  const [generating, setGenerating] = useState(false)

  function updateLineItem(index: number, field: keyof LineItem, value: string) {
    setLineItems((prev) =>
      prev.map((item, i) =>
        i === index
          ? { ...item, [field]: field === 'description' ? value : Number(value) }
          : item
      )
    )
  }

  function addLineItem() {
    setLineItems((prev) => [...prev, { description: '', quantity: 1, unitPrice: 0 }])
  }

  async function generate() {
    if (!invoiceNumber.trim() || !name.trim() || generating) return
    setGenerating(true)

    const res = await fetch('/api/documents/invoice', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        invoiceNumber,
        date: new Date().toLocaleDateString(),
        billTo: { name, email, address },
        lineItems,
      }),
    })

    if (!res.ok) {
      setGenerating(false)
      return
    }

    // Turn the PDF response into a download.
    const blob = await res.blob()
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `invoice-${invoiceNumber}.pdf`
    a.click()
    URL.revokeObjectURL(url)

    setGenerating(false)
  }

  return (
    <div className="document-builder">
      <h1>Invoice Builder</h1>

      <div className="builder-field">
        <label>Invoice Number</label>
        <input value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} />
      </div>

      <div className="builder-field">
        <label>Client Name</label>
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </div>

      <div className="builder-field">
        <label>Client Email</label>
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>

      <div className="builder-field">
        <label>Billing Address</label>
        <input value={address} onChange={(e) => setAddress(e.target.value)} />
      </div>

      <h2>Line Items</h2>
      {lineItems.map((item, i) => (
        <div key={i} className="line-item-row">
          <input
            placeholder="Description"
            value={item.description}
            onChange={(e) => updateLineItem(i, 'description', e.target.value)}
          />
          <input
            type="number"
            placeholder="Qty"
            value={item.quantity}
            onChange={(e) => updateLineItem(i, 'quantity', e.target.value)}
          />
          <input
            type="number"
            placeholder="Unit price"
            value={item.unitPrice}
            onChange={(e) => updateLineItem(i, 'unitPrice', e.target.value)}
          />
        </div>
      ))}

      <button onClick={addLineItem} className="add-line-item">
        + Add line item
      </button>

      <button onClick={generate} disabled={generating || !invoiceNumber.trim()}>
        {generating ? 'Generating...' : 'Generate PDF'}
      </button>
    </div>
  )
}

When the user clicks Generate, the component POSTs the form state to the Route Handler, receives the PDF as a binary blob, and triggers a browser download named after the invoice number. The disabled state on the button and the early return in generate() prevent empty or duplicate submissions while a request is in flight.

5. Add the document builder to a Webflow Cloud page

Because InvoiceBuilder is a Client Component, the page that hosts it is a Server Component wrapper:

// app/document-builder/page.tsx
import InvoiceBuilder from './InvoiceBuilder'

export default function DocumentBuilderPage() {
  return (
    <main className="builder-page">
      <InvoiceBuilder />
    </main>
  )
}

The document builder is now live at /document-builder in your Webflow Cloud app. Mount it at the path you configured in Site Settings > Webflow Cloud. If the builder should be restricted to logged-in users, gate it with Edge runtime auth middleware in middleware.ts.

6. Save generated documents to Webflow Cloud Object Storage

This step is optional. If you only need to hand the PDF to the user, Steps 1 through 5 are complete. If you need a record of every generated document (for auditing, resending, or a "my documents" page), save the PDF to Webflow Cloud Object Storage and store a reference in the Key Value Store.

First, declare both bindings in wrangler.json:

{
  "r2_buckets": [
    { "binding": "DOCUMENTS_BUCKET", "bucket_name": "generated-documents" }
  ],
  "kv_namespaces": [
    { "binding": "DOCUMENTS_KV", "id": "your-kv-namespace-id" }
  ]
}

Then extend the Route Handler to persist before returning:

// Top of the file:
import { getCloudflareContext } from '@opennextjs/cloudflare'

// Inside the POST handler, after generating pdfBytes:
const { env } = getCloudflareContext()

const objectKey = `invoices/${data.invoiceNumber}-${Date.now()}.pdf`

// Store the PDF bytes in Object Storage.
await env.DOCUMENTS_BUCKET.put(objectKey, pdfBytes, {
  httpMetadata: { contentType: 'application/pdf' },
})

// Store a lightweight reference in the KV Store for listing.
await env.DOCUMENTS_KV.put(
  `invoice:${data.invoiceNumber}`,
  JSON.stringify({
    objectKey,
    client: data.billTo.name,
    createdAt: new Date().toISOString(),
  })
)

Now every generated invoice is retrievable later by its key, and you can build a documents list page that reads from the KV Store and serves PDFs back from Object Storage. On the projects where I've added this, the documents list became the feature clients used most.

What breaks a document builder app in Webflow Cloud?

Most failures stem from one of four causes: importing a library that requires Node.js internals that the Workers runtime does not provide, missing font registration, text that overflows the page, or returning the wrong response body type. Each one surfaces predictably.

The symptoms below map to those causes, with the fix for each.

The build fails with an error about a missing Node.js module

A library you imported depends on Node.js internals that the Workers runtime does not provide. This is almost always Puppeteer, pdfkit (which needs node:fs, unavailable on Webflow Cloud), or a PDF library that shells out to a binary.

Confirm you're using pdf-lib, which is pure JavaScript. If you need HTML-to-PDF rendering specifically, that workload belongs on a separate Node.js service, not in a Webflow Cloud Route Handler.

Custom fonts throw "fontkit not registered"

pdf-lib only embeds custom fonts when fontkit is registered. Install @pdf-lib/fontkit, then call pdfDoc.registerFontkit(fontkit) before embedFont with your font bytes. The 14 standard PDF fonts (Helvetica, Times Roman, Courier, and their variants) do not need fontkit; they work out of the box via StandardFonts.

Text overflows the page or overlaps

pdf-lib does not wrap text automatically. Long descriptions run off the edge of the page. Measure text width with the font.widthOfTextAtSize(text, size) and split into multiple lines before drawing, or truncate with an ellipsis.

For documents with unpredictable content length, add a page when cursorY drops below your bottom margin: if (cursorY < margin) { page = pdfDoc.addPage([595, 842]); cursorY = height - margin }.

The downloaded PDF is empty or corrupt

The most common cause is returning the wrong body type. pdfDoc.save() returns a Uint8Array. Pass it directly as the response body; do not JSON.stringify it or wrap it in an object. Also confirm that the Content-Type header is application/pdf, not application/json.

Extend your document builder app in Webflow Cloud

The build in this guide produces one document type from a form. The same architecture scales to a full document platform.

To support multiple document types, add a new drawing module for each type (lib/documents/contract.ts, lib/documents/certificate.ts) and a corresponding Route Handler. The form UI becomes a type selector that routes to the right endpoint.

To pull data from your CMS instead of a manual form, fetch the relevant Webflow CMS item in the Route Handler before generating. A certificate builder, for example, can take a CMS course ID, look up the course and student details, and draw the certificate without any form input at all.

To pre-fill documents from existing PDFs, use pdf-lib's form-filling capability. Load a template PDF with PDFDocument.load, get its form with getForm, and set field values. This is the right approach for government or legal forms that must match an exact official layout.

Frequently asked questions

Can I use Puppeteer to generate PDFs in Webflow Cloud?

No. Puppeteer drives a headless Chromium browser, which requires a full Node.js environment and a Chromium binary. Webflow Cloud runs on Cloudflare Workers, which has neither, so Puppeteer cannot launch a browser. Use pdf-lib for programmatic PDF generation, which is pure JavaScript with no native dependencies. If your document absolutely requires HTML-to-PDF rendering with full CSS layout, run that on a separate Node.js host and call it from Webflow Cloud.

How do I add a company logo or images to generated documents?

pdf-lib embeds PNG and JPEG images. Fetch the image bytes (from a URL or your Object Storage bucket), then call pdfDoc.embedPng(bytes) or pdfDoc.embedJpg(bytes), and draw it with page.drawImage(image, { x, y, width, height }). For a logo, embed it once near the top of the drawing module, in the header area.

Is pdf-lib fast enough for production?

Yes, for the common case. Generating a multi-page text-and-table document is fast because there's no browser to spin up. Performance becomes a concern only with very large documents or heavy image embedding, where the Workers memory and CPU ceilings bind. For typical business documents (invoices, contracts, certificates), it's well within the edge runtime's constraints.

Can users edit a document after it's generated?

Not the PDF itself, but the pattern supports regeneration. Store the structured form data (not just the PDF) in the Key Value Store. When a user wants to edit, load that data back into the form, let them change it, and generate a fresh PDF. This is cleaner than editing a finished PDF and gives you a full history of each document's data.

How do I email the generated document instead of downloading it?

Generate the PDF bytes in the Route Handler as shown, then instead of returning them, send them as an email attachment via a transactional email API. Most email APIs accept a base64-encoded attachment; convert the Uint8Array to a base64 string first, by calling pdfDoc.saveAsBase64() from pdf-lib directly, rather than spreading the array into String.fromCharCode, which exceeds the argument limit on a real PDF. This pairs well with the SendGrid pattern used in other Webflow Cloud guides, where the Route Handler calls the email API directly with fetch.


Last Updated
August 8, 2026
Category

Related articles

The complete guide to syncing Webflow orders to Airtable with Zapier
The complete guide to syncing Webflow orders to Airtable with Zapier

The complete guide to syncing Webflow orders to Airtable with Zapier

The complete guide to syncing Webflow orders to Airtable with Zapier

Development
By
Colin Lateano
,
,
Read article
How to integrate Kajabi online courses with Webflow Cloud using the Public API
How to integrate Kajabi online courses with Webflow Cloud using the Public API

How to integrate Kajabi online courses with Webflow Cloud using the Public API

How to integrate Kajabi online courses with Webflow Cloud using the Public API

Guides
By
Ismail Ajagbe
,
,
Read article
How to build a serverless app with Neon Postgres and Webflow Cloud
How to build a serverless app with Neon Postgres and Webflow Cloud

How to build a serverless app with Neon Postgres and Webflow Cloud

How to build a serverless app with Neon Postgres and Webflow Cloud

Development
By
Colin Lateano
,
,
Read article
How to link Webflow forms to HubSpot without losing your form design
How to link Webflow forms to HubSpot without losing your form design

How to link Webflow forms to HubSpot without losing your form design

How to link Webflow forms to HubSpot without losing your form design

Development
By
Colin Lateano
,
,
Read article

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.