HTML to PDF in Angular: what each option really outputs

You have an Angular component (an invoice, a report, a receipt) and want a "Download PDF" button. Most Angular tutorials reach for html2canvas plus jsPDF, so we built a fresh Angular app, ran that exact pattern on a 40-row invoice component, and then rendered the same component with Angular SSR and printed it with Chromium. Versions: Angular 22.2.3 (standalone, ng new defaults plus ng add @angular/ssr), html2canvas 1.4.1, jsPDF 4.2.1, Express 5.3, Node 22, 21 October 2026. We opened every PDF with poppler.

ApproachWhat came out
html2canvas + jsPDF
the usual tutorial code, in the browser
28.6 MB, 1 page, week 40 and the total cut off, 0 selectable characters, the link gone.
Angular SSR + Chromium API
the app's own server renders the route, then /v1/pdf
20.8 KB, 2 pages, 1,177 selectable characters, the link kept, rows kept whole, the table header repeated on page 2, "1/2" page numbers, 0.3 s.

The test component

An ordinary standalone component with signal inputs: a CSS grid header, a link, a 40-row table and a total. Its CSS sits in styles, so Angular scopes it to the component (emulated encapsulation). That detail matters later: Angular SSR writes these styles into the page as <style> tags, so they travel with the HTML.

// invoice.ts: an ordinary standalone component. Its CSS sits in `styles`, so Angular scopes it.
import { Component, computed, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';

export interface Item { name: string; qty: number; price: number; }

@Component({
  selector: 'app-invoice',
  imports: [CurrencyPipe],
  template: `
    <article class="invoice">
      <header>
        <div><h1>Invoice {{ number() }}</h1><p>Billed to {{ customer() }}</p></div>
        <div class="due">Due 30 Nov 2026<br><a href="https://example.com/pay">Pay online</a></div>
      </header>
      <table>
        <thead><tr><th>Item</th><th>Qty</th><th>Amount</th></tr></thead>
        <tbody>
          @for (item of items(); track $index) {
            <tr><td>{{ item.name }}</td><td>{{ item.qty }}</td><td>{{ item.qty * item.price | currency: 'EUR' }}</td></tr>
          }
        </tbody>
      </table>
      <p class="total">Total {{ total() | currency: 'EUR' }}</p>
    </article>
  `,
  styles: `
    .invoice { font: 14px/1.5 system-ui, sans-serif; color: #1f2937; max-width: 720px; margin: 0 auto; }
    header { display: grid; grid-template-columns: 1fr auto; gap: 24px; border-bottom: 3px solid #4f46e5; padding-bottom: 12px; }
    h1 { margin: 0; color: #4f46e5; }
    .due { text-align: right; }
    table { width: 100%; border-collapse: collapse; margin-top: 16px; }
    th, td { padding: 10px 8px; border-bottom: 1px solid #e5e7eb; text-align: left; }
    td:nth-child(n+2), th:nth-child(n+2) { text-align: right; }
    tr { break-inside: avoid; }
    .total { text-align: right; font-size: 18px; font-weight: 700; }
  `,
})
export class Invoice {
  number = input.required<string>();
  customer = input.required<string>();
  items = input.required<Item[]>();
  total = computed(() => this.items().reduce((s, i) => s + i.qty * i.price, 0));
}

export function demoItems(): Item[] {
  return Array.from({ length: 40 }, (_, i) => ({ name: `Consulting, week ${i + 1}`, qty: 1 + (i % 3), price: 120 }));
}

1. html2canvas + jsPDF (what most tutorials show)

This is the pattern you find in most "Angular HTML to PDF" answers: screenshot the element with html2canvas, scale the image to the A4 width, and add it to a jsPDF document. We ran this file unchanged and clicked the button in Chromium:

// The pattern most Angular tutorials show: html2canvas + jsPDF, one addImage call.
import { Component, ElementRef, viewChild } from '@angular/core';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
import { Invoice, demoItems } from './invoice';

@Component({
  selector: 'app-canvas-pdf',
  imports: [Invoice],
  template: `
    <button id="canvas" (click)="save()">Download PDF</button>
    <div #pdf><app-invoice number="INV-2048" customer="Jane Doe" [items]="items" /></div>
  `,
})
export class CanvasPdf {
  items = demoItems();
  pdf = viewChild.required<ElementRef<HTMLElement>>('pdf');

  async save() {
    const canvas = await html2canvas(this.pdf().nativeElement, { scale: 2 });
    const doc = new jsPDF('p', 'mm', 'a4');
    const width = 208;
    const height = (canvas.height * width) / canvas.width;
    doc.addImage(canvas.toDataURL('image/png'), 'PNG', 0, 0, width, height);
    doc.save('invoice.pdf');
  }
}

The download was a 28,655,686-byte PDF with a single A4 page. Three things went wrong:

The usual fix for the clipping, adding the same image again on each page with a negative offset, gives you more pages but slices whatever row sits on each page edge, because the image knows nothing about rows. jsPDF's own .html() method has the same trade-offs; we measured it in jsPDF HTML to PDF.

2. Render with Angular SSR, print with Chromium

If your app already uses Angular SSR (or you add it with ng add @angular/ssr), the server can render any route to HTML. Give the invoice its own route, render it on the server and send that HTML to a real Chromium. You get the browser's own layout (grid, web fonts, break-inside, a repeated table header), real text and links, and the API key stays on your server.

The print route is a thin page around the same component. It reads the invoice number from the URL, which needs provideRouter(routes, withComponentInputBinding()) in app.config.ts:

// invoice-page.ts: the route the server prints. Load the real invoice from your API here.
import { Component, input } from '@angular/core';
import { Invoice, demoItems } from './invoice';

@Component({
  selector: 'app-invoice-page',
  imports: [Invoice],
  template: `<app-invoice [number]="number()" customer="Jane Doe" [items]="items" />`,
})
export class InvoicePage {
  number = input.required<string>(); // from the :number route param (withComponentInputBinding)
  items = demoItems();
}

In app.routes.ts it's { path: 'invoice/:number', component: InvoicePage }. In app.routes.server.ts, render it per request rather than prerendering it at build time, because the number is only known per request:

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: 'invoice/:number', renderMode: RenderMode.Server },
  { path: '**', renderMode: RenderMode.Prerender },
];

Then add this route to the src/server.ts that ng add @angular/ssr generated, above the express.static block. It's the exact code we ran:

/**
 * GET /invoice/INV-2048.pdf: render the Angular route on the server, then print it with Chromium.
 */
app.get('/invoice/:number.pdf', async (req, res, next) => {
  const { number } = req.params;
  try {
    // Ask the SSR engine that serves your pages for the print route instead (it reads originalUrl).
    req.originalUrl = `/invoice/${encodeURIComponent(number)}`;
    const page = await angularApp.handle(req);
    if (!page) return next();
    // A rejected host or a failed render is still a Response: don't print the error page.
    if (!page.ok) return res.status(page.status).send(await page.text());
    const html = await page.text();

    const r = await fetch('https://shotmint.creasework.com/v1/pdf', {
      method: 'POST',
      headers: { 'X-API-Key': process.env['SHOTMINT_KEY']!, 'Content-Type': 'application/json' },
      body: JSON.stringify({ html, format: 'A4', margin: '12mm', page_numbers: true }),
    });

Build with ng build and start with SHOTMINT_KEY=your_key node dist/your-app/server/server.mjs. A link in the app is all the client needs:

// pdf-link.ts: the browser only asks your server for the file; the API key never reaches the client.
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-pdf-link',
  template: `<a [href]="'/invoice/' + number() + '.pdf'" download>Download PDF</a>`,
})
export class PdfLink {
  number = input.required<string>();
}

Result on the same invoice: a 20,849-byte A4 PDF with 2 pages, 1,177 characters of selectable text, the "Pay online" link clickable, the break falling between weeks 20 and 21 (no sliced row), the table header repeated at the top of page 2, the total on page 2, and "1/2" and "2/2" at the bottom from page_numbers. The whole request took 0.3 s.

Three Angular SSR traps we hit

The SSR page also links styles-*.css, main-*.js and the favicon with relative URLs, which can't load when the HTML is printed on its own. That's harmless here: the component styles are already inline <style> tags and the page doesn't need its scripts. If your print layout depends on global styles.css, move those rules into the component or inline the file into the HTML. (While writing this guide we found our renderer waited 5 s on such dead stylesheet links before printing; that's fixed, hence the 0.3 s.)

Which one to use

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription. Get a free key to try the route above.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons