PDFPipe

Guide

HTML to PDF in Nuxt.js

Nuxt has no built-in PDF output and the common client-side workarounds produce low-fidelity results. The correct approach is a server-side call from Nuxt's Nitro engine. PDFPipe integrates cleanly with Nitro's defineEventHandler and returns a proper PDF binary with no extra dependencies.

Nuxt server API route

Create server/api/pdf.post.ts. Nuxt auto-registers it at POST /api/pdf. The handler reads the HTML from the request body, calls PDFPipe, and streams the binary back:

typescript
// server/api/pdf.post.ts
// POST /api/pdf — accepts { html, filename? } and streams a PDF.
import { defineEventHandler, readBody, createError } 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 || typeof html !== "string") {
    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", margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" } },
    }),
  });

  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 headers and return the stream — Nitro pipes it to the client.
  event.node.res.setHeader("Content-Type", "application/pdf");
  event.node.res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);

  return upstream.body;
});

// From any Vue component or composable:
// const response = await $fetch("/api/pdf", {
//   method: "POST",
//   body: { html: myHtml, filename: "invoice.pdf" },
//   responseType: "blob",
// });
// const url = URL.createObjectURL(response);
// window.open(url, "_blank");

Server middleware for auth

Server middleware runs before every matching route. Use it to validate an internal token so the PDFPipe key never touches the client. The invoice route below stays clean, relying on middleware for authentication:

typescript
// server/middleware/pdf-auth.ts
// Server middleware that validates an API token on /api/pdf/* routes.
// This runs before the route handler, keeping auth out of the handler itself.
import { defineEventHandler, getHeader, createError } from "h3";

export default defineEventHandler((event) => {
  if (!event.path?.startsWith("/api/pdf")) return;

  const bearer = getHeader(event, "authorization") ?? "";
  const token = bearer.replace(/^Bearer\s+/i, "");

  // Validate against your app's internal token, not the PDFPipe key.
  const expected = process.env.INTERNAL_API_TOKEN;
  if (!expected || token !== expected) {
    throw createError({ statusCode: 401, message: "Unauthorized" });
  }
});

// server/api/pdf/invoice/[id].get.ts
// GET /api/pdf/invoice/42 — individual invoice handler (protected by middleware above).
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!;

  const html = `
    <!DOCTYPE html>
    <html>
    <body style="font-family: system-ui; padding: 40px">
      <h1>Invoice #${id}</h1>
    </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) throw createError({ statusCode: 502, message: "PDF render failed" });

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

Store and return a CDN URL

Pass store: true to persist the document. The server API route returns a typed JSON payload with the document ID, CDN URL, and expiry. Use navigateTo or window.open in your composable to send the user directly to the file:

typescript
// server/api/pdf/store.post.ts
// POST /api/pdf/store — stores the PDF on the CDN and returns the URL.
import { defineEventHandler, readBody, createError } from "h3";

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

interface StoreRequest {
  html: string;
  filename?: string;
  format?: string;
}

interface StoreResponse {
  docId: string | null;
  docUrl: string | null;
  expiresAt: string | null;
}

export default defineEventHandler(async (event): Promise<StoreResponse> => {
  const { html, filename = "document.pdf", format = "A4" } =
    await readBody<StoreRequest>(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 },
      store: true,
      filename,
    }),
  });

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

  return {
    docId:     upstream.headers.get("X-PDFPipe-Document-Id"),
    docUrl:    upstream.headers.get("X-PDFPipe-Document-Url"),
    expiresAt: upstream.headers.get("X-PDFPipe-Document-Expires"),
  };
});

// Vue composable usage:
// const { docUrl } = await $fetch("/api/pdf/store", {
//   method: "POST",
//   body: { html, filename: "report.pdf" },
// });
// navigateTo(docUrl, { external: true });

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 →