You can generate, store, and download a finished invoice PDF from your Webflow site without standing up a separate backend.
Generating a branded invoice PDF from your own data typically requires setting up a separate server, since Webflow doesn't run backend logic on its own. Webflow Cloud closes that gap: you deploy a Next.js app on the same infrastructure as your site and give it a SQLite database for the invoice records. The PDF renders on the Workers runtime, with no external print service.
Because the app runs on the same origin as your Webflow site, the browser calls a relative path, so there's no cross-origin request to configure.
In this guide, we'll build the whole flow: a schema for invoices and line items, a route that creates and lists them, then a PDF handler that turns a stored invoice into a downloadable file.
What do you need to build an invoice generator app in Webflow Cloud?
You need a Webflow Cloud app running Next.js 15 or higher, a SQLite database binding for the records, and one pure-JavaScript PDF library that runs on the edge. Nothing here requires a separate backend host.
Here is the full list before you write any code:
- A Webflow site with Webflow Cloud available in the settings (included from the free Starter plan up, with higher app and storage limits on paid plans)
- A GitHub account, since Webflow Cloud deploys from a connected repository
- A Next.js 15+ app (the Webflow Cloud Next.js starter template is the fastest start)
- Node.js 22.13.0 or higher and npm installed locally, which is the minimum for Webflow CLI 2.0 and the only supported package manager
drizzle-ormfor queries, plusdrizzle-kit,tsx, andbetter-sqlite3as dev dependencies for the schema and local migrationspdf-libfor generating the invoice PDF on the Workers runtime
The one choice worth making deliberately is the PDF library. I use pdf-lib because it is pure JavaScript and runs inside the Workers runtime with no native binaries, which is exactly what the edge environment needs.
Libraries like PDFKit lean on Node.js streams and the file system, and those fail on Webflow Cloud. With the prerequisites ready, the build splits into seven steps.
7 steps to build an invoice generator app in Webflow Cloud
The flow is the same shape as any small CRUD app, with one addition. You scaffold and configure the project, add a database and schema, write the Route Handlers that create and list invoices, add a handler that renders a PDF, build the form, and deploy. The PDF route is the only piece unique to invoicing, and it is the one most affected by the edge runtime.
Each step below builds on the previous one, so work through them in order rather than jumping to the PDF handler first.
1. Create and configure a Webflow Cloud Next.js project
Start the project from the Next.js starter template in the Webflow Cloud deploy wizard, which clones a ready-to-deploy repository into your GitHub account and sets up the first environment. Open the wizard from your Webflow dashboard, connect GitHub, choose the Next.js starter, and pick a mount path such as /app when you attach it to an existing site.
Clone the new repository locally and open next.config.ts.
The starter sets basePath and assetPrefix to your mount path, and they must match it exactly:
// next.config.ts
module.exports = {
basePath: '/app',
assetPrefix: '/app',
}
This configuration is what lets every asset and API route resolve correctly once the app is served from yourdomain.com/app instead of the domain root. The starter also includes webflow.json, open-next.config.ts, and wrangler.json, so you do not have to assemble the OpenNext and Wrangler setup by hand.
Run npm install, then confirm the app builds locally before adding anything to it.
2. Add a SQLite database and define the invoice schema
Add a SQLite database binding to wrangler.json so Webflow Cloud provisions a database for the app.
Declare it in the d1_databases array, and leave the database_id as a placeholder, since Webflow Cloud generates the real value on your first deploy:
"d1_databases": [
{
"binding": "DB",
"database_name": "invoices_db",
"database_id": "placeholder",
"migrations_dir": "drizzle"
}
]
The binding value, DB, is the variable name you use to reach the database in code, so keep it consistent with the helper in the next step.
With the binding declared, install Drizzle and its local-migration tooling with npm i drizzle-orm and npm i -D drizzle-kit tsx better-sqlite3, then define the tables.
An invoice is two related records, the invoice header and its line items, so the schema uses two tables:
// src/db/schema/index.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const invoices = sqliteTable('invoices', {
id: integer().primaryKey({ autoIncrement: true }),
number: text().notNull().unique(),
clientName: text('client_name').notNull(),
clientEmail: text('client_email').notNull(),
status: text().notNull().default('draft'),
currency: text().notNull().default('USD'),
issuedAt: text('issued_at').notNull(),
dueAt: text('due_at'),
createdAt: text('created_at').notNull(),
})
export const invoiceItems = sqliteTable('invoice_items', {
id: integer().primaryKey({ autoIncrement: true }),
invoiceId: integer('invoice_id')
.notNull()
.references(() => invoices.id),
description: text().notNull(),
quantity: integer().notNull(),
unitAmount: integer('unit_amount').notNull(),
})
The detail I never skip is storing money as integer cents in unitAmount, not as a floating-point dollar value. Floats drift: summing several line items in dollars produces totals that land a cent off often enough to matter on a document someone pays from. Integers remove that whole class of bugs.
Generate and apply the migration using npm run db:generate and npm run db:apply:local; the local database is ready.
3. Create a database helper for your Route Handlers
Centralize the database connection in one helper so every Route Handler reaches the same Drizzle instance with the schema attached.
On Webflow Cloud, the binding lives on the Cloudflare context rather than process.env, so you read it through getCloudflareContext from the OpenNext adapter:
// src/db/getDb.ts
import { drizzle } from 'drizzle-orm/d1'
import { getCloudflareContext } from '@opennextjs/cloudflare'
import * as schema from './schema'
export function getDb() {
const { env } = getCloudflareContext()
return drizzle(env.DB, { schema })
}
The env.DB reference resolves to the same DB binding you named in wrangler.json, and passing { schema } gives Drizzle the typed tables so queries stay type-safe. Run npm run cf-typegen once to regenerate cloudflare-env.d.ts so TypeScript knows the DB binding exists on the Cloudflare environment.
I keep this helper tiny on purpose because every route imports it, and any logic added here runs on every request. With the connection in place, the handlers can start writing and reading invoices.
4. Build the invoice creation Route Handler
Add a Route Handler that accepts an invoice payload, writes the header, then writes its line items.
Create src/app/api/invoices/route.ts with a POST export. Do not add a runtime directive: the OpenNext Cloudflare adapter that Webflow Cloud uses does not support the Next.js Edge runtime, and its setup guide tells you to remove any such line. Route Handlers run on the Node.js runtime:
// src/app/api/invoices/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getDb } from '@/db/getDb'
import { invoices, invoiceItems } from '@/db/schema'
type LineItem = { description: string; quantity: number; unitAmount: number }
type CreateInvoiceBody = {
clientName: string
clientEmail: string
currency?: string
dueAt?: string
items: LineItem[]
}
export async function POST(request: NextRequest) {
const body = (await request.json()) as CreateInvoiceBody
if (!body.clientName || !body.items?.length) {
return NextResponse.json(
{ error: 'A client name and at least one line item are required' },
{ status: 400 }
)
}
const db = getDb()
const now = new Date().toISOString()
const number = `INV-${now.slice(0, 10).replace(/-/g, '')}-${Math.floor(1000 + Math.random() * 9000)}`
const [invoice] = await db
.insert(invoices)
.values({
number,
clientName: body.clientName,
clientEmail: body.clientEmail,
currency: body.currency ?? 'USD',
issuedAt: now,
dueAt: body.dueAt ?? null,
createdAt: now,
})
.returning()
await db.insert(invoiceItems).values(
body.items.map((item) => ({
invoiceId: invoice.id,
description: item.description,
quantity: item.quantity,
unitAmount: item.unitAmount,
}))
)
return NextResponse.json({ id: invoice.id, number: invoice.number }, { status: 201 })
}
The handler validates the payload first, then generates a human-readable invoice number from the date plus a random suffix so two invoices created the same day do not collide. The .returning() call hands back the inserted row, including the auto-incremented id, which the second insert uses as the foreign key for each line item.
The response stays minimal on purpose, returning just the id and number the client needs to fetch the PDF next.
5. List invoices with a GET handler
Add a read endpoint so the app can show past invoices and so you can confirm records are persisting.
Extend the same route.ts file with a GET export that returns invoices newest first:
// src/app/api/invoices/route.ts (continued)
import { desc } from 'drizzle-orm'
export async function GET() {
const db = getDb()
const rows = await db.select().from(invoices).orderBy(desc(invoices.createdAt))
return NextResponse.json(rows)
}
Because Next.js maps each HTTP method in the file to its matching export, this GET lives alongside the POST from the previous step and shares the same getDb helper and the same imports at the top of the file.
The query returns only the invoice headers, not the line items, which keeps the list response small. When a single invoice view needs its items, fetch them in a dedicated route by invoiceId. At this point you can create and list invoices, so the next step turns a stored record into a document.
6. Generate the invoice PDF at the edge with pdf-lib
Add a dynamic Route Handler that loads a single invoice with its line items and generates a PDF in memory.
Install the library with npm install pdf-lib, then create src/app/api/invoices/[id]/pdf/route.ts:
// src/app/api/invoices/[id]/pdf/route.ts
import { type NextRequest } from 'next/server'
import { eq } from 'drizzle-orm'
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
import { getDb } from '@/db/getDb'
import { invoices, invoiceItems } from '@/db/schema'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const db = getDb()
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, Number(id)))
if (!invoice) return new Response('Invoice not found', { status: 404 })
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id))
const pdf = await PDFDocument.create()
const page = pdf.addPage([595, 842]) // A4 in points
const font = await pdf.embedFont(StandardFonts.Helvetica)
const bold = await pdf.embedFont(StandardFonts.HelveticaBold)
const money = (cents: number) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: invoice.currency,
}).format(cents / 100)
page.drawText('INVOICE', { x: 50, y: 780, size: 24, font: bold })
page.drawText(invoice.number, { x: 50, y: 758, size: 12, font })
page.drawText(`Bill to: ${invoice.clientName}`, { x: 50, y: 720, size: 12, font })
let y = 670
page.drawText('Description', { x: 50, y, size: 11, font: bold })
page.drawText('Qty', { x: 360, y, size: 11, font: bold })
page.drawText('Amount', { x: 470, y, size: 11, font: bold })
page.drawLine({
start: { x: 50, y: y - 6 },
end: { x: 545, y: y - 6 },
thickness: 1,
color: rgb(0.8, 0.8, 0.8),
})
let total = 0
for (const item of items) {
y -= 24
const lineTotal = item.quantity * item.unitAmount
total += lineTotal
page.drawText(item.description, { x: 50, y, size: 11, font })
page.drawText(String(item.quantity), { x: 360, y, size: 11, font })
page.drawText(money(lineTotal), { x: 470, y, size: 11, font })
}
page.drawText('Total', { x: 360, y: y - 30, size: 12, font: bold })
page.drawText(money(total), { x: 470, y: y - 30, size: 12, font: bold })
const bytes = await pdf.save()
return new Response(bytes, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${invoice.number}.pdf"`,
},
})
}
The handler reads the invoice and its items, then builds the document entirely in memory, which suits the Workers runtime since there is no file system to write to. Note that params is awaited, because Next.js 15 made dynamic route parameters asynchronous.
I compute each line total and the total from the integer cents and only format to currency at draw time with Intl.NumberFormat, so the math never touches a float. pdf.save() returns a Uint8Array, which the Response accepts directly, and the Content-Type and Content-Disposition headers tell the browser to download it as a named file.
Hitting /app/api/invoices/1/pdf now returns a finished invoice.
7. Build the invoice form and deploy
Add a Client Component form so a person can enter line items instead of posting JSON by hand, then deploy. The one rule that trips people up is the fetch path: client-side calls must include the mount path, because a bare /api/invoices resolves to the domain root, not your app:
// src/app/components/InvoiceForm.tsx
'use client'
import { useState } from 'react'
const BASE_PATH = '/app' // must match your Webflow Cloud mount path
export function InvoiceForm() {
const [clientName, setClientName] = useState('')
const [clientEmail, setClientEmail] = useState('')
const [items, setItems] = useState([{ description: '', quantity: 1, unitAmount: 0 }])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const res = await fetch(`${BASE_PATH}/api/invoices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientName, clientEmail, items }),
})
const { id } = await res.json()
window.location.href = `${BASE_PATH}/api/invoices/${id}/pdf`
}
// ... inputs that update clientName, clientEmail, and items ...
return <form onSubmit={handleSubmit}>{/* fields and a submit button */}</form>
}
The form posts the invoice and reads the returned id, then sends the browser straight to the PDF route, so a single submit produces a downloadable invoice. Capture unitAmount in cents in the inputs, or multiply by 100 before posting, to match the schema.
To ship, commit and push to the connected GitHub branch, or run webflow auth login followed by webflow cloud deploy. Webflow Cloud applies the database migration automatically on deploy, and the live app appears at your domain plus the mount path once the deployment finishes.
What breaks an invoice generator app in Webflow Cloud?
Most failures trace back to the edge runtime or the mount path: a Node.js API the Workers runtime does not support, a fetch that forgot the base path, a migration that never ran in production, or money math done in floats. Each one produces a recognizable symptom, and each has a direct fix.
The list below pairs the symptom you will see with its cause, starting with the deploy-time failure that confuses people most.
The deployed app returns a 500, but works locally
The most common cause is a leftover export const runtime = "edge" directive, which the OpenNext Cloudflare adapter does not support. Remove it from every source file. If no directive is present and a route 500s only in production, the likely cause is a package reaching for a Node.js API the Workers runtime doesn't support.
The Workers runtime supports many Node modules through nodejs_compat, including Buffer, crypto, stream, and path. What it does not give you at Webflow Cloud's default compatibility date is fs, so any package that imports it fails to resolve.
Check the build logs in the Webflow Cloud dashboard for a message like Could not resolve 'fs', then swap the offending library for an edge-compatible one. This is exactly why the guide uses pdf-lib rather than a PDF library built on Node streams.
Client-side requests return 404 in production
The fetch path is missing the mount-path prefix. In Next.js on Webflow Cloud, your API routes are served under the base path, so a Client Component calling /api/invoices hits the domain root and misses your app entirely.
Prefix every client fetch with the mount path, as the form does with ${BASE_PATH}/api/invoices. Server-side route definitions are mounted automatically and do not need the prefix, which is why this only shows up in browser calls and never in local server logs.
A query fails with "no such table: invoices"
The migration ran locally but not against the deployed database. Local development applies migrations through npm run db:apply:local, which only touches the local SQLite file.
Webflow Cloud applies migrations from your drizzle directory on deploy, so the fix is to generate the migration with npm run db:generate, commit the generated files in drizzle, and push.
If the migration files are not committed, the production database never gets the schema, and every query against a missing table throws.
The PDF totals are off by a cent
The amounts are being stored or summed as floating-point dollars. JavaScript cannot represent values like 0.1 exactly, so summing several line items drifts. Store every amount as integer cents in the database and sum the integers. Divide by 100 only at the moment you format with Intl.NumberFormat.
If existing rows already hold dollar floats, migrate them by multiplying by 100 and rounding once, then change the column to hold integers going forward.
Extend your Webflow Cloud invoice generator
The app so far stores invoices and renders them, which is the core loop. The natural extensions turn it from a generator into a billing workflow, and each one slots into a Route Handler you already understand.
The most requested addition is payment. Once an invoice exists, you can set up Stripe Checkout in Webflow and mark the invoice paid from a webhook, or follow the pattern in this guide to build a full-stack app on Webflow Cloud with Supabase, Auth0, and Stripe when you need accounts and payments together.
To deliver invoices instead of relying on manual downloads, you cansend order confirmation emails from Webflow using SendGrid.
If clients need to see their own invoices, gate the routes by adding Auth0 authentication to a Webflow site rather than hiding content in the browser, and watch the live app with Sentry error tracking on a Webflow Cloud app.
When you are ready to give yourself an overview of outstanding balances, the approach behind a real-time dashboard with Supabase and Webflow maps cleanly onto invoice data.
Frequently asked questions
Can Webflow Cloud generate PDFs without an external service?
Yes. A pure-JavaScript library like pdf-lib runs inside the Cloudflare Workers runtime that powers Webflow Cloud, building the document in memory and returning the bytes from a Route Handler. No external print API or headless browser is required for structured documents like invoices.
Why use SQLite instead of an external database?
SQLite ships as a native Webflow Cloud storage option, so you add it with a binding in wrangler.json and no separate hosting. Drizzle ORM gives typed queries on top. Reach for Neon or Supabase only when you need shared Postgres access across systems.
Do I have to set the runtime to edge on every API route?
No, and you should remove the directive if you have it. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js Edge runtime and instructs you to delete any such line before deploying. Your Route Handlers run on the Node.js runtime on top of the Workers runtime.
Why do my client-side fetch calls need the mount path?
Because the app is served from a mount path like /app, not the domain root. Next.js mounts your API routes under that base path automatically, but browser fetches do not know it, so a call to /api/invoices misses the app. Prefix client fetches with the mount path.
How large can an invoice PDF get on Webflow Cloud?
Comfortably large for invoicing. Webflow Cloud gives each Worker 128 MB of memory and 30 seconds of CPU time per request, while pdf-lib holds the whole document in memory. A multi-page invoice is tens of kilobytes, so you stay far inside those limits even when generating many at once.
Can I store the generated PDFs instead of rendering them each time?
Yes. Add a Webflow Cloud Object Storage bucket and write the generated bytes there, keyed by invoice number. Rendering on demand is simpler and keeps a single source of truth in the database, so store copies only when you need an immutable archive or faster repeat downloads.




