Building Blazor Print‑Friendly Documents: Generate PDFs and Reports On‑Demand

Blazor PDFs & Reports: Print‑Ready Docs, Excel & CSV

Add PDFs, Excel, and CSV exports to your Blazor app. Clean print styles, server‑side converters, and quick endpoints you can copy today.

.NET Development Blazor·By amarozka · November 8, 2025

Blazor PDFs & Reports: Print‑Ready Docs, Excel & CSV

If your Blazor app can render a screen, it can render a bill. Sounds bold? In one sprint, I wired up invoices, PDFs, and Excel/CSV exports without dragging the team into a months‑long side quest. In this post I’ll show you the exact setup I use so you can ship printable docs today, not “after the rewrite”.

What you’ll build

  • A print‑ready Razor component with clean layout and proper page breaks.
  • Server endpoints that turn HTML into a PDF using SelectPdf or iText 7.
  • One‑click exports to Excel (.xlsx) and CSV.
  • Styling rules that look good on screen and on paper.
  • A tiny JavaScript bridge for in‑browser printing.

Works with Blazor Server and Blazor WebAssembly (WASM). For WASM, the PDF/Excel generation runs on the server via an API.

Quick map of the flow

[Blazor UI] --click--> [API /export/pdf] --HTML--> [PDF engine] --bytes--> [Browser download]
[Blazor UI] --click--> [API /export/xlsx] -> [ClosedXML] -> .xlsx
[Blazor UI] --click--> [API /export/csv]   -> stream CSV text

Step 1: Build a print‑ready Razor component

Create a focused component that renders only the content you want in the document. Avoid sticky headers, sidebars, and random UI chrome.

Invoice.razor

@using System.Globalization
@inherits LayoutComponentBase
@code {
    [Parameter] public InvoiceDto? Model { get; set; }
    string Money(decimal v) => v.ToString("C", CultureInfo.InvariantCulture);
}

<div id="print-root" class="doc a4">
  <header class="doc-header">
    <h1>Invoice @Model?.Number</h1>
    <div class="meta">
      <span>Date: @Model?.Date:dd MMM yyyy</span>
      <span>Customer: @Model?.CustomerName</span>
    </div>
  </header>

  <section class="items">
    <table class="grid">
      <thead>
        <tr><th>#</th><th>Description</th><th>Qty</th><th>Price</th><th>Total</th></tr>
      </thead>
      <tbody>
        @foreach (var (item, idx) in Model!.Lines.Select((x,i) => (x, i+1)))
        {
          <tr>
            <td>@idx</td>
            <td>@item.Title</td>
            <td class="num">@item.Qty</td>
            <td class="num">@Money(item.Price)</td>
            <td class="num">@Money(item.Qty * item.Price)</td>
          </tr>
        }
      </tbody>
    </table>
  </section>

  <footer class="totals">
    <div>Subtotal: <strong>@Money(Model!.Lines.Sum(l => l.Qty * l.Price))</strong></div>
    <div>Tax (20%): <strong>@Money(Model!.Lines.Sum(l => l.Qty * l.Price) * 0.2m)</strong></div>
    <div class="grand">Grand Total: <strong>@Money(Model!.Lines.Sum(l => l.Qty * l.Price) * 1.2m)</strong></div>
  </footer>
</div>

doc.css (include in wwwroot/css/doc.css and reference it)

/* Screen defaults */
.doc { font: 14px/1.4 system-ui, Segoe UI, Roboto, Arial, sans-serif; color:#111; }
.doc-header { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:16px; }
.doc-header h1 { margin:0; font-size:22px; }
.meta { display:flex; gap:16px; font-size:12px; color:#444; }
.grid { width:100%; border-collapse:collapse; }
.grid th, .grid td { border:1px solid #ddd; padding:6px 8px; }
.grid th { text-align:left; }
.num { text-align:right; white-space:nowrap; }
.totals { margin-top:16px; display:grid; gap:6px; }
.totals .grand { font-size:18px; }

/* Print: A4 with safe margins and clear breaks */
@page { size: A4; margin: 16mm 14mm; }
@media print {
  html, body { height: auto; }
  .doc { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  .doc-header { page-break-inside: avoid; }
  .items { page-break-inside: auto; }
  .items .grid tr { page-break-inside: avoid; page-break-after: auto; }
  .totals { page-break-inside: avoid; }
  /* Hide site chrome when printing */
  .app-shell, nav, .btn-toolbar { display:none !important; }
}

Hook the component

<!-- InvoicePage.razor -->
@page "/invoice/{id:int}"
@inject HttpClient Http

<PageTitle>Invoice</PageTitle>

@if (_model is null)
{
    <p>Loading…</p>
}
else
{
    <link rel="stylesheet" href="css/doc.css" />
    <Invoice Model="_model" />

    <div class="btn-toolbar">
        <button @onclick="Print">Print</button>
        <button @onclick="ExportPdf">Export PDF</button>
        <button @onclick="ExportXlsx">Export Excel</button>
        <button @onclick="ExportCsv">Export CSV</button>
    </div>
}

@code {
    [Parameter] public int Id { get; set; }
    InvoiceDto? _model;

    protected override async Task OnInitializedAsync()
    {
        _model = await Http.GetFromJsonAsync<InvoiceDto>($"api/invoices/{Id}");
    }

    async Task Print() => await JS.InvokeVoidAsync("window.print");

    async Task ExportPdf()
        => await Download($"api/export/pdf/{Id}", $"invoice-{Id}.pdf", "application/pdf");

    async Task ExportXlsx()
        => await Download($"api/export/xlsx/{Id}", $"invoice-{Id}.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

    async Task ExportCsv()
        => await Download($"api/export/csv/{Id}", $"invoice-{Id}.csv", "text/csv;charset=utf-8");

    async Task Download(string url, string fileName, string mime)
    {
        var bytes = await Http.GetByteArrayAsync(url);
        await JS.InvokeVoidAsync("blazorSaveFile", bytes, fileName, mime);
    }
}

wwwroot/saveFile.js

window.blazorSaveFile = (bytes, fileName, mime) => {
  const blob = new Blob([bytes], { type: mime });
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = fileName;
  link.click();
  URL.revokeObjectURL(link.href);
};

Register the script in index.html (WASM) or _Host.cshtml (Server):

<script src="saveFile.js"></script>

Step 2: The API that feeds your document

Define a shared DTO so UI and backend speak the same language.

public record InvoiceDto(int Id, string Number, DateTime Date, string CustomerName, List<InvoiceLine> Lines);
public record InvoiceLine(string Title, int Qty, decimal Price);

Create minimal endpoints that serve data and exports.

// Program.cs (.NET 8)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
app.UseStaticFiles();
app.UseSwagger();
app.UseSwaggerUI();

// Sample data
var invoices = new List<InvoiceDto> {
    new(1, "INV-2025-001", DateTime.UtcNow.Date, "Contoso Ltd",
        new(){ new("License A", 2, 199m), new("Support", 1, 99m) })
};

app.MapGet("/api/invoices/{id:int}", (int id)
    => invoices.FirstOrDefault(x => x.Id == id) is { } inv ? Results.Ok(inv) : Results.NotFound());

app.Run();

You now have consistent data for both the screen and the exports.

Step 3: Convert HTML → PDF

You have two popular engines in .NET land:

  • SelectPdf – simple API, very good HTML/CSS support (paid, has free community tier with limits).
  • iText 7 with pdfHTML – powerful, mature; dual license (AGPL/commercial). Use the commercial path for closed‑source apps.

Important: run converters on the server. Don’t try to push them into the browser in WASM.

A) Using SelectPdf

Add the NuGet package SelectPdf.NetCore and a small service.

// PdfController.cs (Minimal API style kept inline for brevity)
app.MapGet("/api/export/pdf/{id:int}", async (int id, HttpContext ctx) =>
{
    var http = ctx.RequestServices.GetRequiredService<IHttpClientFactory>().CreateClient();
    var inv = await http.GetFromJsonAsync<InvoiceDto>($"https://localhost:5001/api/invoices/{id}");
    if (inv is null) return Results.NotFound();

    // Build HTML. In real apps, use a Razor template engine; here we keep it short.
    var html = HtmlTemplates.Invoice(inv);
    var baseUrl = ctx.Request.Scheme + "://" + ctx.Request.Host + "/"; // so <link href="css/doc.css"> works

    using var converter = new SelectPdf.HtmlToPdf();
    converter.Options.PdfPageSize = SelectPdf.PdfPageSize.A4;
    converter.Options.MarginTop = 20;
    converter.Options.MarginBottom = 18;
    converter.Options.JavaScriptEnabled = true; // allow client-like layout if needed

    using var doc = converter.ConvertHtmlString(html, baseUrl);
    var bytes = doc.Save();

    return Results.File(bytes, "application/pdf", $"invoice-{id}.pdf");
});

HTML template helper (ultra simple on purpose):

public static class HtmlTemplates
{
    public static string Invoice(InvoiceDto m)
    {
        var sb = new System.Text.StringBuilder();
        sb.Append("<html><head>");
        sb.Append("<link rel=\"stylesheet\" href=\"css/doc.css\" />");
        sb.Append("</head><body>");
        sb.Append("<div class='doc'>");
        sb.Append($"<h1>Invoice {m.Number}</h1>");
        sb.Append($"<div class='meta'><span>Date: {m.Date:dd MMM yyyy}</span><span>Customer: {m.CustomerName}</span></div>");
        sb.Append("<table class='grid'><thead><tr><th>#</th><th>Description</th><th>Qty</th><th>Price</th><th>Total</th></tr></thead><tbody>");
        for (var i = 0; i < m.Lines.Count; i++)
        {
            var l = m.Lines[i];
            var total = l.Qty * l.Price;
            sb.Append($"<tr><td>{i+1}</td><td>{l.Title}</td><td class='num'>{l.Qty}</td><td class='num'>{l.Price:C}</td><td class='num'>{total:C}</td></tr>");
        }
        sb.Append("</tbody></table>");
        var sub = m.Lines.Sum(x => x.Price * x.Qty);
        sb.Append($"<div class='totals'>Subtotal: <b>{sub:C}</b><br/>Tax (20%): <b>{sub*0.2m:C}</b><br/><span class='grand'>Grand Total: <b>{sub*1.2m:C}</b></span></div>");
        sb.Append("</div></body></html>");
        return sb.ToString();
    }
}

B) Using iText 7 (pdfHTML)

Add packages itext7 and itext7.pdfhtml.

app.MapGet("/api/export/pdf-itext/{id:int}", async (int id, HttpContext ctx) =>
{
    var http = ctx.RequestServices.GetRequiredService<IHttpClientFactory>().CreateClient();
    var inv = await http.GetFromJsonAsync<InvoiceDto>($"https://localhost:5001/api/invoices/{id}");
    if (inv is null) return Results.NotFound();

    var html = HtmlTemplates.Invoice(inv);
    var baseUrl = ctx.Request.Scheme + "://" + ctx.Request.Host + "/";

    using var ms = new MemoryStream();
    var props = new iText.Html2pdf.ConverterProperties();
    props.SetBaseUri(baseUrl);

    iText.Html2pdf.HtmlConverter.ConvertToPdf(html, ms, props);

    return Results.File(ms.ToArray(), "application/pdf", $"invoice-{id}.pdf");
});

Tip: embed fonts for consistent output. Both engines let you register fonts so currency symbols and local scripts render well.

Step 4: Export to Excel (.xlsx)

I use ClosedXML for clean, readable code.

app.MapGet("/api/export/xlsx/{id:int}", (int id) =>
{
    // fetch your invoice… (mocked here)
    var inv = new InvoiceDto(id, $"INV-{id:000}", DateTime.UtcNow.Date, "Tailwind Traders",
        new() { new("Service A", 3, 50), new("Service B", 1, 80) });

    using var wb = new ClosedXML.Excel.XLWorkbook();
    var ws = wb.AddWorksheet("Invoice");

    ws.Cell(1,1).Value = "Invoice"; ws.Cell(1,2).Value = inv.Number;
    ws.Cell(2,1).Value = "Date";    ws.Cell(2,2).Value = inv.Date;
    ws.Cell(3,1).Value = "Customer";ws.Cell(3,2).Value = inv.CustomerName;

    ws.Cell(5,1).Value = "#";
    ws.Cell(5,2).Value = "Description";
    ws.Cell(5,3).Value = "Qty";
    ws.Cell(5,4).Value = "Price";
    ws.Cell(5,5).Value = "Total";

    var row = 6;
    for (var i = 0; i < inv.Lines.Count; i++)
    {
        var l = inv.Lines[i];
        ws.Cell(row,1).Value = i+1;
        ws.Cell(row,2).Value = l.Title;
        ws.Cell(row,3).Value = l.Qty;
        ws.Cell(row,4).Value = l.Price;
        ws.Cell(row,5).FormulaA1 = $"C{row}*D{row}";
        row++;
    }

    ws.Columns().AdjustToContents();

    using var ms = new MemoryStream();
    wb.SaveAs(ms);
    return Results.File(ms.ToArray(),
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        $"invoice-{id}.xlsx");
});

Step 5: Export to CSV

Fast, portable, no frills.

app.MapGet("/api/export/csv/{id:int}", (int id) =>
{
    var inv = new InvoiceDto(id, $"INV-{id:000}", DateTime.UtcNow.Date, "Tailwind Traders",
        new() { new("Service A", 3, 50), new("Service B", 1, 80) });

    var sb = new System.Text.StringBuilder();
    sb.AppendLine("No,Description,Qty,Price,Total");
    for (var i = 0; i < inv.Lines.Count; i++)
    {
        var l = inv.Lines[i];
        var total = l.Qty * l.Price;
        // basic CSV escaping
        string esc(string s) => '"' + s.Replace("\"", "\"\"") + '"';
        sb.AppendLine(string.Join(',', new [] {
            (i+1).ToString(), esc(l.Title), l.Qty.ToString(), l.Price.ToString("0.00"), total.ToString("0.00")
        }));
    }

    var bytes = System.Text.Encoding.UTF8.GetBytes(sb.ToString());
    return Results.File(bytes, "text/csv; charset=utf-8", $"invoice-{id}.csv");
});

Styling for paper: the small rules that make a big difference

  • Use @page to set page size and margins. A4, Letter, or custom.
  • Avoid page breaks inside rows with page-break-inside: avoid for table rows.
  • Pin headers and totals by avoiding breaks around them.
  • Use real text for totals, not screenshots. PDF engines can’t “read” pixels.
  • Color on paper: some browsers mute colors when printing. Add print-color-adjust: exact.
  • Links: show URLs in print with CSS a[href]:after { content: " (" attr(href) ")"; } if needed.
  • Typeface: register fonts in the PDF engine so the output matches the screen.

Blazor Server vs WASM: what goes where

  • Blazor Server: everything is already on the server. Converters run in‑process.
  • Blazor WASM: call a backend API for PDF/Excel/CSV. Keep the WASM app lean.
  • Shared DTOs: put models in a shared project (MyApp.Shared) so both sides stay in sync.

Security & licensing notes (short, but key)

  • Licenses: SelectPdf and iText have commercial terms. Read them and match your use case. If your app is closed source, avoid AGPL unless you have a commercial key.
  • Input: never feed raw user HTML to the converter. Sanitize or build your own HTML from trusted data.
  • Pacing: add rate limits on export endpoints to stop spam.
  • Files at rest: if you store PDFs, encrypt at rest and avoid putting them under web‑root.

Troubleshooting cheat sheet

  • My totals cut across pages → Add page-break-inside: avoid and wrap the totals block in a footer.
  • Styles not applied in PDF → Ensure you pass a base URL to the converter so <link href="css/..."> resolves.
  • Missing icons → Use inline SVG or register the icon font in the converter.
  • Wrong currency symbol → Embed fonts that contain your symbol and set culture when formatting money.
  • Blank PDF → Some engines need full HTML (<html><head>…). Build full documents, not just fragments.

Performance tips

  • Cache CSS: serve a single doc.css. Avoid inline styles.
  • Stream output: return FileStreamResult rather than buffering huge files in memory for very large reports.
  • Parallel exports: queue long reports (big data) and notify users via email/SignalR when ready.
  • Re‑use templates: don’t string‑concat big HTML in hot paths. Use a compiled template engine for speed.

FAQ: common questions on Blazor PDFs & reports

Can I print the page directly without a PDF?

Yes. window.print() gives you a quick win. Use the same doc.css with @media print rules.

Which engine should I pick first?

If you need fast results and simple code, try SelectPdf. If you need deep control and are fine with the license model, iText 7 is solid.

What about charts and images?

Render them in the HTML and make sure the image URLs are absolute or under the same base path. For charts, export as inline SVG.

Can I export the exact same table to Excel?

Yes. Read your data model (not the DOM) and write rows to ClosedXML so totals and formulas remain real spreadsheet data.

How do I handle long tables?

Use table headers (<thead>) so engines repeat headers across pages. Keep row heights consistent.

Any gotchas with WASM?

Keep conversion on the server. In the browser, stick to preview/print and file download.

Conclusion: ship print‑ready docs without slowing your roadmap

You don’t need a new stack to ship nice PDFs and exports. Keep your UI lean, reuse the same HTML for screen and paper, and push heavy lifting to the server. Start with one invoice, get it out, then expand to quotes, purchase orders, and long reports. Your users get clean files; you keep your focus on core features.

What export gave your users the most value-PDF, Excel, or CSV? Drop a comment with your case and stack.

Leave a Reply

Your email address will not be published. Required fields are marked *