Guide
HTML to PDF with tRPC
tRPC is a TypeScript-first RPC framework popular with Next.js full-stack teams. Because tRPC infers types from your server router all the way to your React components, PDF generation becomes a first-class, fully typed operation in your app: no REST endpoint boilerplate, no manual fetch wrappers in every component, and automatic error handling through the tRPC error boundary. Define the procedure once, call it everywhere.
tRPC router
Create a pdfRouter in server/routers/pdf.ts. The generate mutation takes a Zod-validated input of { html, filename }, calls the PDF API with store: true, and returns the stored document URL. The Zod schema doubles as runtime validation and the TypeScript source of truth for the client type:
// server/routers/pdf.ts
import { z } from "zod";
import { router, protectedProcedure } from "../trpc";
export const pdfRouter = router({
generate: protectedProcedure
.input(
z.object({
html: z.string().min(1).max(500_000),
filename: z.string().min(1).default("document.pdf"),
})
)
.mutation(async ({ input }) => {
const { html, filename } = input;
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
filename,
options: { format: "A4" },
store: true,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { detail?: string }).detail ?? "PDF generation failed");
}
// Drain the body so the connection is released.
await res.arrayBuffer();
const url = res.headers.get("X-PDFPipe-Document-Url");
if (!url) throw new Error("No document URL returned");
return { url, filename };
}),
});Merge into the app router
Add pdfRouter to your main appRouter in server/routers/index.ts. The key you choose here (pdf) is the namespace the client uses to call the procedure:
// server/routers/index.ts
import { router } from "../trpc";
import { pdfRouter } from "./pdf";
// ...your other routers
import { userRouter } from "./user";
export const appRouter = router({
pdf: pdfRouter,
user: userRouter,
// ...
});
export type AppRouter = typeof appRouter;React client call
Use trpc.pdf.generate.useMutation() in any client component. The onSuccess callback receives the fully typed return value ({ url, filename }) inferred directly from the router. The onError callback receives a typed tRPC error with the message thrown in the procedure:
// components/ExportPdfButton.tsx
"use client";
import { trpc } from "@/lib/trpc/client";
interface Props {
html: string;
filename?: string;
}
export function ExportPdfButton({ html, filename = "document.pdf" }: Props) {
const generatePdf = trpc.pdf.generate.useMutation({
onSuccess(data) {
// Open the stored document in a new tab.
window.open(data.url, "_blank", "noopener,noreferrer");
},
onError(err) {
console.error("PDF generation failed:", err.message);
// Surface the error however suits your UI — toast, alert, etc.
alert("Could not generate PDF. Please try again.");
},
});
return (
<button
type="button"
disabled={generatePdf.isPending}
onClick={() => generatePdf.mutate({ html, filename })}
>
{generatePdf.isPending ? "Generating..." : "Export PDF"}
</button>
);
}Streaming bytes vs. storing a URL
The store: true pattern used above is the right fit for async workflows, background jobs, email attachments, and any case where the document must be retrieved later. For an immediate browser download, the API returns the PDF bytes directly when store is omitted. tRPC mutations return JSON, so the streaming path is best handled by a thin Next.js Route Handler sitting alongside your tRPC routes, rather than through the tRPC layer itself:
// For an immediate browser download, skip store: true and stream the bytes instead.
// The API returns the PDF body directly when store is omitted or false.
//
// In a Next.js Route Handler (app/api/pdf/route.ts):
export async function POST(req: Request) {
const { html } = await req.json();
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: { format: "A4" } }),
});
if (!upstream.ok) return new Response("render failed", { status: 502 });
return new Response(upstream.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="document.pdf"',
},
});
}
//
// Call this route from the client with a plain fetch() and use
// URL.createObjectURL() to trigger the download — no tRPC needed for
// the streaming path because it returns bytes, not JSON.Full parameter reference for both modes is on the API docs page.
Environment variable
Add your API key to .env.local. Next.js loads this file automatically in development and on your deployment platform. Do not prefix it with NEXT_PUBLIC_: the key must remain server-side only and is never sent to the browser:
# .env.local
PDFPIPE_API_KEY=your_api_key_hereGetting 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 →