Guide
HTML to PDF in Electron
Electron ships with a built-in win.webContents.printToPDF() method, but it has real limitations: formatting inconsistencies, no custom headers or footers, and in some configurations the window must be visible during the call. The other common approach, spawning a local binary, means shipping extra megabytes with your installer and managing a process that can hang. Calling a PDF API from the main process avoids all of that. One fetch call, one response, done.
Main process handler
All PDF logic lives in the Electron main process. The handler receives an HTML string and a filename from the renderer via ipcRenderer.invoke, calls the PDF API with the key from process.env.PDFPIPE_API_KEY, and returns the raw bytes as a Buffer. The renderer never touches the key:
// main.ts (Electron main process)
// npm install dotenv
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
import { writeFile } from "fs/promises";
import { join } from "path";
import "dotenv/config";
const API_KEY = process.env.PDFPIPE_API_KEY;
if (!API_KEY) {
throw new Error("PDFPIPE_API_KEY is not set. Add it to your .env file.");
}
ipcMain.handle(
"generate-pdf",
async (_event, { html, filename }: { html: string; filename: string }) => {
const response = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: { format: "A4" } }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error((err as any).detail ?? "PDF generation failed");
}
// Read the response as a Buffer and return it to the renderer.
const arrayBuffer = await response.arrayBuffer();
return { buffer: Buffer.from(arrayBuffer), filename };
}
);
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile("index.html");
});The preload script exposes the IPC call to the renderer through contextBridge. Context isolation is required, and nodeIntegration should stay false:
// preload.ts — exposes a safe bridge to the renderer
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("electronAPI", {
generatePdf: (args: { html: string; filename: string }) =>
ipcRenderer.invoke("generate-pdf", args),
});Renderer side
The renderer builds an HTML string from your report template, calls window.electronAPI.generatePdf, and saves the result. Vanilla JS and React both work the same way. The buffer returned from the main process is a Uint8Array in the renderer context, so wrap it in a Blob before triggering a download:
// renderer.ts (or a React component inside your renderer)
// This code runs in the browser context of your Electron window.
async function exportReport() {
const html = buildReportHtml({
title: "Q2 Sales Report",
rows: getSalesData(),
});
const result = await window.electronAPI.generatePdf({
html,
filename: "q2-sales-report.pdf",
});
// result.buffer is a Uint8Array — convert to Blob for download.
const blob = new Blob([result.buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
}
// ---
// If you prefer the native Save dialog instead of a browser download,
// handle that in the main process after ipcMain.handle resolves:
//
// const { filePath } = await dialog.showSaveDialog({ defaultPath: filename });
// if (filePath) await writeFile(filePath, Buffer.from(arrayBuffer));A full React component that builds a minimal HTML document, sends it for rendering, and downloads the result:
// ReportButton.tsx — React renderer component
// (runs inside the Electron window, no Node.js access)
"use client"; // only needed if you're also doing Next.js SSR
import { useState } from "react";
export function ReportButton() {
const [loading, setLoading] = useState(false);
async function handleExport() {
setLoading(true);
try {
const html = `
<html>
<head>
<style>
body { font-family: Arial, sans-serif; padding: 40px; }
h1 { color: #1d1812; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ccc; padding: 8px 12px; text-align: left; }
</style>
</head>
<body>
<h1>Monthly Summary</h1>
<p>Generated on ${new Date().toLocaleDateString()}</p>
<table>
<thead><tr><th>Item</th><th>Amount</th></tr></thead>
<tbody>
<tr><td>Revenue</td><td>$42,000</td></tr>
<tr><td>Expenses</td><td>$18,500</td></tr>
<tr><td>Net</td><td>$23,500</td></tr>
</tbody>
</table>
</body>
</html>
`;
const result = await window.electronAPI.generatePdf({
html,
filename: "monthly-summary.pdf",
});
const blob = new Blob([result.buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
} finally {
setLoading(false);
}
}
return (
<button onClick={handleExport} disabled={loading}>
{loading ? "Generating..." : "Export PDF"}
</button>
);
}With store: true for sharing
Pass store: trueto keep the document on a CDN and receive a stable URL in the response headers. The main process can then open the URL in the user's default browser with shell.openExternal(url), or pass it to an email API. No file bytes need to travel between processes:
// main.ts — alternative IPC handler using store: true
// Returns a URL instead of raw bytes. Useful for sharing or emailing.
ipcMain.handle(
"generate-pdf-url",
async (_event, { html, filename }: { html: string; filename: string }) => {
const response = 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" },
store: true,
filename,
}),
});
if (!response.ok) {
throw new Error("PDF generation failed");
}
// Drain the body — we only need the response headers.
await response.arrayBuffer();
const documentUrl = response.headers.get("X-PDFPipe-Document-Url");
const documentId = response.headers.get("X-PDFPipe-Document-Id");
const expiresAt = response.headers.get("X-PDFPipe-Document-Expires");
return { documentUrl, documentId, expiresAt };
}
);
// ---
// Renderer calls this instead of "generate-pdf":
//
// const { documentUrl } = await window.electronAPI.generatePdfUrl({ html, filename });
//
// Then in the main process, open it in the default browser:
// ipcMain.handle("open-external", (_event, url: string) => shell.openExternal(url));
//
// Or email it — documentUrl is a stable link you can pass to any email API.Securing the API key
The renderer process is a browser context. Any user can open DevTools and inspect memory, variables, and network requests. The API key must never leave the main process. Do not pass it through contextBridge, do not set it on window, and do not embed it in the renderer bundle. Load it in the main process only via process.env, populated by dotenvor electron-builder's extraResources approach for packaged apps:
# .env (project root, never commit this file)
PDFPIPE_API_KEY=your_api_key_here
# ---
# Option A: load with dotenv at the top of main.ts (before other imports)
# npm install dotenv
import "dotenv/config";
# Option B: electron-builder extraResources (for packaged apps)
# In electron-builder.config.js:
# extraResources: [{ from: ".env", to: ".env" }]
#
# Then in main.ts, resolve the path relative to process.resourcesPath:
import { config } from "dotenv";
import { join } from "path";
const envPath = app.isPackaged
? join(process.resourcesPath, ".env")
: join(__dirname, "../../.env");
config({ path: envPath });
# IMPORTANT: the API key must only live in the main process.
# Never expose it to the renderer via contextBridge or window globals.
# The renderer process can be inspected by any user with DevTools.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 →