Guide
HTML to PDF in Convex
Convex is a full-stack TypeScript backend popular with React and Next.js developers. It gives you a reactive database, typed queries, mutations, and actions that can call external APIs. PDF generation fits naturally as a Convex action: when an invoice is created, a background action calls the PDF API, then writes the document URL back into the database. The React component that renders the invoice updates automatically via a live query, no polling required.
Overview
Convex separates database writes (mutations) from arbitrary async work (actions). An action can make HTTP requests and read environment variables, but it runs outside the database transaction. A mutation is transactional and fast, but it cannot call external services. The pattern here is: mutation creates the record and schedules the action, action generates the PDF and calls an internal mutation to write the result back.
// Convex is a full-stack TypeScript backend platform for React and Next.js apps.
// It has three main building blocks:
// - mutations: transactional writes to the Convex database
// - queries: reactive reads that re-run when data changes
// - actions: arbitrary async code that can call external APIs
//
// PDF generation fits naturally in an action: actions can make HTTP requests,
// read environment variables, and write results back to the database.
//
// Install the Convex client:
// npm install convex
//
// Authenticate and initialize your project:
// npx convex dev
//
// Set your API key in the Convex dashboard (Settings > Environment Variables):
// PDFPIPE_API_KEY=your_api_key_hereAction: generate the PDF
The generateInvoicePdf action uses the "use node" directive, which opts the file into the Node.js runtime. This is required for Convex actions that make outbound HTTP requests. The action calls the PDF API with store: true so the rendered document is persisted and a stable URL is returned. It then calls an internal mutation to write the URL back to the invoices table:
// convex/pdf.ts
// "use node" opts this file into the Node.js runtime.
// Convex actions that call external HTTP APIs require it.
"use node";
import { action } from "./_generated/server";
import { v } from "convex/values";
export const generateInvoicePdf = action({
args: {
invoiceId: v.id("invoices"),
html: v.string(),
},
handler: async (ctx, args) => {
const apiKey = process.env.PDFPIPE_API_KEY;
if (!apiKey) {
throw new Error("PDFPIPE_API_KEY is not set");
}
const response = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: args.html,
options: { format: "A4" },
// store: true persists the rendered document and returns a stable URL.
// Without it the response is a byte stream that cannot be stored in Convex.
store: true,
filename: `invoice-${args.invoiceId}.pdf`,
}),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
const detail = (err as { detail?: string }).detail ?? "render failed";
throw new Error(`PDF generation failed: ${detail}`);
}
const result = await response.json();
const documentUrl: string = result.document_url;
// Write the document URL back to the invoices table.
// ctx.runMutation executes a transactional write from inside an action.
await ctx.runMutation(internal.pdf.storeDocumentUrl, {
invoiceId: args.invoiceId,
documentUrl,
});
return { documentUrl };
},
});Mutation: create and schedule
The createInvoice mutation inserts the new record and immediately schedules the PDF action using ctx.scheduler.runAfter(0, ...). A delay of 0 means the action runs as soon as the mutation commits. The client gets the invoice ID right away, and the PDF is generated in the background without blocking the response. The internal storeDocumentUrl mutation is only callable from other Convex functions, keeping the write server-side:
// convex/pdf.ts (continued)
// This internal mutation is called by the action above to persist the URL.
// Internal functions cannot be called from the client — only from other
// Convex functions. That keeps this write operation server-side only.
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const storeDocumentUrl = internalMutation({
args: {
invoiceId: v.id("invoices"),
documentUrl: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.invoiceId, {
documentUrl: args.documentUrl,
pdfGeneratedAt: Date.now(),
});
},
});
// convex/invoices.ts
// When a new invoice is created, schedule the PDF action to run immediately
// in the background. The mutation returns right away; the PDF is generated
// asynchronously without blocking the client.
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const createInvoice = mutation({
args: {
customerId: v.id("customers"),
html: v.string(),
amountCents: v.number(),
},
handler: async (ctx, args) => {
const invoiceId = await ctx.db.insert("invoices", {
customerId: args.customerId,
amountCents: args.amountCents,
html: args.html,
documentUrl: null,
pdfGeneratedAt: null,
createdAt: Date.now(),
});
// Schedule the PDF action to run after the mutation commits.
// ctx.scheduler.runAfter(0, ...) fires immediately once the transaction
// is durable — the 0 ms delay means "as soon as possible".
await ctx.scheduler.runAfter(0, internal.pdf.generateInvoicePdf, {
invoiceId,
html: args.html,
});
return invoiceId;
},
});Scheduled jobs with ctx.scheduler
For PDFs that should be ready at a specific future time, use ctx.scheduler.runAt instead of runAfter. Convex durably persists the schedule in the database, so the job survives server restarts and deploys. This pattern works well for monthly statements, deferred billing documents, or any PDF tied to a known future event:
// convex/invoices.ts
// You can also schedule PDF generation at a specific time using runAt.
// This is useful for deferred billing documents, scheduled reports,
// or any PDF that should be ready before a known future event.
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const scheduleMonthlyStatement = mutation({
args: {
accountId: v.id("accounts"),
html: v.string(),
// Unix timestamp (ms) when the PDF should be generated.
generateAt: v.number(),
},
handler: async (ctx, args) => {
const statementId = await ctx.db.insert("statements", {
accountId: args.accountId,
html: args.html,
documentUrl: null,
scheduledFor: args.generateAt,
createdAt: Date.now(),
});
// ctx.scheduler.runAt schedules the action at a specific timestamp.
// Convex durably persists the schedule — even a server restart will
// not lose the pending job.
await ctx.scheduler.runAt(
args.generateAt,
internal.pdf.generateInvoicePdf,
{
invoiceId: statementId,
html: args.html,
}
);
return statementId;
},
});Query: read the document URL
A Convex query returns the invoice with its documentUrl field. On the React side, useQuery subscribes to this query over a WebSocket. When the background action writes the URL, Convex pushes the update and the component re-renders automatically. The documentUrl field starts as null and becomes a string once the action completes:
// convex/invoices.ts
// A reactive query that returns the invoice with its document URL.
// In a React component, useQuery re-renders automatically when the
// document URL is written by the action — no polling needed.
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getInvoice = query({
args: { invoiceId: v.id("invoices") },
handler: async (ctx, args) => {
return await ctx.db.get(args.invoiceId);
},
});React component
The React component calls useQuery with the invoice ID. While documentUrl is null, it shows a placeholder. As soon as the action writes the URL, the component updates and renders the download link. No manual fetch, no polling, no useEffect needed:
// app/invoices/[id]/page.tsx
// The React component reads the invoice via useQuery.
// When the background action writes the documentUrl, Convex pushes the
// update over a WebSocket and the component re-renders automatically.
"use client";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
interface InvoicePageProps {
params: { id: string };
}
export default function InvoicePage({ params }: InvoicePageProps) {
const invoice = useQuery(api.invoices.getInvoice, {
invoiceId: params.id as Id<"invoices">,
});
if (invoice === undefined) {
return <p>Loading...</p>;
}
if (invoice === null) {
return <p>Invoice not found.</p>;
}
return (
<div>
<h1>Invoice #{invoice._id}</h1>
<p>Amount: ${(invoice.amountCents / 100).toFixed(2)}</p>
{invoice.documentUrl ? (
<a href={invoice.documentUrl} target="_blank" rel="noopener noreferrer">
Download PDF
</a>
) : (
// The document URL is null until the background action completes.
// useQuery updates this automatically — no manual refresh needed.
<p>Generating PDF...</p>
)}
</div>
);
}Environment variables
Convex actions read environment variables from the Convex dashboard, not from a local .env file. Set PDFPIPE_API_KEYunder Settings > Environment Variables in your Convex project dashboard. The value is encrypted at rest and never exposed to the client:
# Set these in the Convex dashboard under Settings > Environment Variables.
# Do NOT use a .env file for Convex actions — process.env in actions reads
# from dashboard-configured variables, not local .env files.
PDFPIPE_API_KEY=your_api_key_here
# For local development, run:
# npx convex dev
# This starts the local Convex backend and syncs your functions automatically.Getting an API key
The Hobby plan gives 500 free documents a month with no credit card required. Paid plans start at $19 and include a longer document archive, higher limits, and email support.
Full API reference on the docs page. Try it now in the live playground with no signup.
Get an API key →