Guide
HTML to PDF in Inngest
Inngest is a developer platform for durable, event-driven functions popular across the Next.js and Node.js ecosystem. PDF generation fits naturally as an Inngest workflow: when an order is confirmed, send a pdf/invoice.requested event, generate the invoice PDF as a step, then email the link to the customer as the next step. Each step is checkpointed and retried independently if it fails. No custom retry logic, no queue infrastructure to manage.
Overview
Inngest functions are defined with inngest.createFunction and hosted as a single HTTP endpoint inside your existing application. The Inngest platform calls that endpoint to execute each step, persists step results between calls, and handles retries with exponential backoff. You do not run a separate worker process: the function code lives alongside your Next.js routes.
// Inngest lets you write durable, event-driven workflows as plain TypeScript.
// Each step in a function is retried independently if it fails.
//
// Install the Inngest SDK:
// npm install inngest
//
// Set your API key in .env:
// PDFPIPE_API_KEY=your_api_key_here
//
// Inngest functions are hosted alongside your Next.js app.
// The serve() handler registers them with the Inngest platform.Create the Inngest client
The client is a shared instance used by both functions (to define them) and API routes (to send events). Create it once and import it wherever needed:
// src/inngest/client.ts
// Create a shared Inngest client used by all functions and event senders.
// The id should match your app name in the Inngest dashboard.
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "my-app" });Define the function
The generateInvoice function listens for pdf/invoice.requested events and runs two steps in sequence. Each step.run block is a durable checkpoint: its return value is stored by Inngest after the first successful execution. If the function is interrupted between steps, Inngest replays it and skips the already-completed steps, passing the persisted return values forward.
Passing store: true to the PDF API is important here: it returns a persistent document URL rather than a byte stream. That URL is a plain string that Inngest can store and pass to the email step, even across a function replay:
// src/inngest/functions.ts
// Define the invoice function. It runs whenever a "pdf/invoice.requested"
// event is received. Each step.run block is executed and retried independently.
import { inngest } from "./client";
export const generateInvoice = inngest.createFunction(
{
id: "generate-invoice",
// Inngest retries each step automatically on unhandled errors.
// The default is 4 retries with exponential backoff.
retries: 5,
},
{ event: "pdf/invoice.requested" },
async ({ event, step }) => {
const { orderId, html, recipientEmail } = event.data;
// Step 1: Generate the PDF.
// step.run wraps the work in a durable checkpoint. If the function
// is interrupted or this step throws, Inngest retries only this step,
// not the whole function. The return value is persisted in Inngest's
// state store and passed to subsequent steps.
const { documentUrl } = await step.run("generate-pdf", async () => {
const apiKey = process.env.PDFPIPE_API_KEY;
if (!apiKey) {
// A missing API key is a permanent misconfiguration, not a transient
// failure. Throw a NonRetriableError so Inngest stops retrying
// immediately instead of exhausting the retry budget.
const { NonRetriableError } = await import("inngest");
throw new NonRetriableError("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,
options: { format: "A4" },
// store: true returns a persistent URL that survives across steps.
// Without it the response is a byte stream you cannot pass forward.
store: true,
filename: `invoice-${orderId}.pdf`,
}),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
const detail = (err as { detail?: string }).detail ?? "render failed";
// Throw a NonRetriableError for 4xx client errors.
// For 5xx server errors, throw a regular Error so Inngest retries.
if (response.status >= 400 && response.status < 500) {
const { NonRetriableError } = await import("inngest");
throw new NonRetriableError(detail);
}
throw new Error(detail);
}
const result = await response.json();
return { documentUrl: result.document_url as string };
});
// Step 2: Send the invoice email.
// documentUrl was returned by the previous step and is available here
// even if the function was interrupted and resumed between the two steps.
await step.run("send-invoice-email", async () => {
// Replace with your email provider (Resend, SendGrid, etc.).
console.log(
`Sending invoice for order ${orderId} to ${recipientEmail}`
);
console.log(`PDF: ${documentUrl}`);
});
return { orderId, documentUrl };
}
);Register the serve handler
The serve() helper from inngest/next registers your functions as a Next.js App Router route. Inngest discovers this endpoint during deployment and uses it to invoke functions, receive step results, and confirm execution. All three HTTP methods must be exported:
// src/app/api/inngest/route.ts
// Register all Inngest functions with the Next.js App Router.
// Inngest calls this endpoint to discover, invoke, and report back to your functions.
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import { generateInvoice } from "@/inngest/functions";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [generateInvoice],
});Trigger from an API route
Sending an event with inngest.send() is fire-and-forget: the call returns as soon as Inngest acknowledges the event. The function runs asynchronously in the background. Call this from any Next.js API route, server action, or webhook handler where the order is confirmed:
// src/app/api/orders/route.ts
// Send a "pdf/invoice.requested" event from a Next.js API route.
// Inngest receives the event and triggers the generateInvoice function.
import { NextResponse } from "next/server";
import { inngest } from "@/inngest/client";
export async function POST(request: Request) {
const body = await request.json();
const { orderId, html, recipientEmail } = body;
await inngest.send({
name: "pdf/invoice.requested",
data: {
orderId,
html,
recipientEmail,
},
});
return NextResponse.json({ status: "queued", orderId });
}Environment variables
For local development, run the Inngest dev server with npx inngest-cli@latest dev. It connects to your local Next.js app automatically and replays events through your functions with a real-time dashboard. In production, set the signing key and event key from your Inngest dashboard:
# .env (project root)
PDFPIPE_API_KEY=your_api_key_here
# For local development, start the Inngest dev server:
# npx inngest-cli@latest dev
# It proxies events to your local Next.js app automatically.
# No additional env var is needed for local development.
# In production, set the signing key from your Inngest dashboard:
INNGEST_SIGNING_KEY=signkey-prod-xxxxxxxxxxxx
INNGEST_EVENT_KEY=xxxxxxxxxxxxxxxxxxxxGetting 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 →