PDFPipe

Guide

HTML to PDF in Vue.js

Vue has no built-in PDF output. Client-side libraries that walk the DOM produce inconsistent results and cannot handle server-rendered pages. The right approach is a server-side HTTP call that returns a real PDF binary. PDFPipe works with any Node backend including Express or the Nitro server bundled with Nuxt.

Express backend route

For a Vue SPA backed by Express, add a route that builds your HTML, calls PDFPipe, and pipes the binary stream to the response. The Vue frontend simply navigates to the URL:

typescript
// server/routes/pdf.ts — Express backend for a Vue SPA
// npm install express
import express, { type Request, type Response } from "express";

const router = express.Router();
const API_URL = "https://api.pdfpipe.xyz/v1/pdf";

router.get("/invoices/:id/pdf", async (req: Request, res: Response) => {
  const apiKey = process.env.PDFPIPE_API_KEY;
  if (!apiKey) {
    res.status(500).json({ error: "PDFPIPE_API_KEY not configured" });
    return;
  }

  const { id } = req.params;
  const html = buildInvoiceHtml(id);

  const upstream = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, options: { format: "A4" } }),
  });

  if (!upstream.ok) {
    const err = await upstream.json().catch(() => ({})) as Record<string, unknown>;
    res.status(502).json({ error: String(err.detail ?? "PDF render failed") });
    return;
  }

  res.setHeader("Content-Type", "application/pdf");
  res.setHeader("Content-Disposition", `attachment; filename="invoice-${id}.pdf"`);

  // Pipe the upstream body to the Express response without buffering.
  const reader = upstream.body!.getReader();
  const pump = async () => {
    const { done, value } = await reader.read();
    if (done) { res.end(); return; }
    res.write(value);
    await pump();
  };
  await pump();
});

function buildInvoiceHtml(id: string): string {
  return `
    <!DOCTYPE html>
    <html>
    <body style="font-family: system-ui; padding: 40px">
      <h1>Invoice #${id}</h1>
      <p>Thank you for your purchase.</p>
    </body>
    </html>
  `;
}

export default router;

// In your Express app entry point:
// app.use("/api", pdfRouter);

Nitro server route (Nuxt 3)

Nuxt 3 uses Nitro as its server engine. Drop a file into server/routes/ and it is auto-registered. The h3 event handler calls PDFPipe and returns the stream, which Nitro pipes to the response:

typescript
// server/routes/pdf/[id].get.ts — Nitro server route (Nuxt 3)
// This file is auto-discovered by Nuxt; no registration required.
import { defineEventHandler, getRouterParam, createError } from "h3";

const API_URL = "https://api.pdfpipe.xyz/v1/pdf";

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
  const apiKey = process.env.PDFPIPE_API_KEY;

  if (!apiKey) throw createError({ statusCode: 500, message: "API key not configured" });

  const html = `
    <!DOCTYPE html>
    <html>
    <body style="font-family: system-ui; padding: 40px">
      <h1>Invoice #${id}</h1>
      <p>Thank you for your order.</p>
    </body>
    </html>
  `;

  const upstream = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, options: { format: "A4" } }),
  });

  if (!upstream.ok) {
    const body = await upstream.json().catch(() => ({})) as Record<string, unknown>;
    throw createError({ statusCode: 502, message: String(body.detail ?? "PDF render failed") });
  }

  // Set response headers and return the readable stream.
  event.node.res.setHeader("Content-Type", "application/pdf");
  event.node.res.setHeader("Content-Disposition", `attachment; filename="invoice-${id}.pdf"`);

  return upstream.body;
});

// Access via GET /api/pdf/42 from any Vue component:
// window.location.href = `/api/pdf/${invoiceId}`;

Store and return a download URL

Pass store: true to keep the PDF on the CDN. The Nitro action returns the URL as JSON so your Vue component can open it in a new tab, display a link, or persist it to your database:

typescript
// server/routes/reports/generate.post.ts — Nitro action (Nuxt 3)
// Returns a JSON payload with the CDN URL instead of streaming the file.
import { defineEventHandler, readBody, createError, sendRedirect } from "h3";

const API_URL = "https://api.pdfpipe.xyz/v1/pdf";

export default defineEventHandler(async (event) => {
  const { html, filename = "document.pdf" } = await readBody<{
    html: string;
    filename?: string;
  }>(event);

  if (!html) throw createError({ statusCode: 400, message: "html is required" });

  const apiKey = process.env.PDFPIPE_API_KEY;
  if (!apiKey) throw createError({ statusCode: 500, message: "API key not configured" });

  const upstream = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: { format: "A4" },
      store: true,
      filename,
    }),
  });

  if (!upstream.ok) {
    throw createError({ statusCode: 502, message: "PDF render failed" });
  }

  const docId      = upstream.headers.get("X-PDFPipe-Document-Id");
  const docUrl     = upstream.headers.get("X-PDFPipe-Document-Url");
  const expiresAt  = upstream.headers.get("X-PDFPipe-Document-Expires");

  // Return to the Vue component as JSON — let the client decide whether
  // to redirect, open in a new tab, or store the URL for later.
  return { docId, docUrl, expiresAt };
});

// Vue component usage:
// const { docUrl } = await $fetch("/api/reports/generate", {
//   method: "POST",
//   body: { html, filename: "report.pdf" },
// });
// window.open(docUrl, "_blank");

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 →