PDF Reporting in .NET With HTML Templates and PuppeteerSharp
A beginner-friendly guide to making PDF reports in .NET for free using HTML templates and PuppeteerSharp, a headless Chromium browser.
Think about a wedding invitation card. The printer does not design a brand-new card for every guest. There is one beautiful design. For each guest, the printer just changes the name and the address, then prints the same card again. Same look, different details.
Making PDF reports in .NET works best the exact same way. You design the report once as a template made of HTML and CSS. Then, for each invoice or each customer, you fill in fresh data and print it to a PDF. The tool we will use to do the printing is called PuppeteerSharp.
In this guide you will learn how to make clean, professional PDFs in .NET for free. We will write an HTML template, fill it with data, and let a small hidden browser turn that HTML into a PDF. The words will stay simple and the steps will stay small.
What is PuppeteerSharp?
PuppeteerSharp is a .NET library. It is a port of a famous Node.js tool called Puppeteer. Its job is to control a headless browser.
A headless browser is just Google Chrome (or its open-source twin, Chromium) running with no window. You cannot see it on the screen, but it can still open pages, run JavaScript, and load CSS, exactly like the Chrome on your laptop. PuppeteerSharp tells this hidden browser what to do from your C# code.
Here is the big idea. Chrome already has a "Print to PDF" feature. You have probably used it yourself: open a web page, press Ctrl+P, and save it as a PDF. PuppeteerSharp lets your code do that same thing automatically, with no human clicking buttons.
Because the printing is done by a real browser, the PDF looks exactly like the web page. Fonts, colors, tables, and spacing all come out right. There are no strange surprises.
Why use HTML for reports?
You might wonder why we draw the report as a web page instead of placing text and lines by hand on a blank page.
The answer is simple: you already know HTML and CSS. Most developers do. With HTML you can build a table in a few lines. With CSS you can add colors, borders, and nice fonts. You can even reuse your company's website styles. Drawing a PDF shape by shape, on the other hand, is slow and painful.
Here is a quick comparison of the two ways to build a PDF.
| Approach | How you build it | Good for | Pain points |
|---|---|---|---|
| HTML to PDF (PuppeteerSharp) | Write HTML and CSS | Invoices, reports, certificates | Needs a browser running |
| Draw by hand (low-level library) | Place text and lines by coordinates | Tiny labels, simple receipts | Hard to style, slow to build |
For most business reports, the HTML way wins. It is faster to build and easier to change later.
Installing PuppeteerSharp
First, add the NuGet package to your project. You can do this from the terminal.
// Run this in your project folder:
// dotnet add package PuppeteerSharp
// PuppeteerSharp needs a copy of Chromium to control.
// This downloads a known-good copy the first time you run it.
using PuppeteerSharp;
var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync();The BrowserFetcher downloads a matching copy of Chromium for you. You only need this download once. On a server, many teams run this step at startup or when building the Docker image, so the browser is ready before the first report.
Your first PDF from HTML
Let's make the smallest possible example. We will take a little piece of HTML and turn it into a PDF.
using PuppeteerSharp;
// 1. Start the hidden browser.
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
// 2. Open a new blank page (like a new tab).
await using var page = await browser.NewPageAsync();
// 3. Put our HTML onto the page.
var html = "<h1>Hello, PDF!</h1><p>My first report.</p>";
await page.SetContentAsync(html);
// 4. Print the page to a PDF file.
await page.PdfAsync("hello.pdf");That is the whole flow. Read it slowly:
- Launch the browser.
- Open a page.
- Set the HTML.
- Print to PDF.
SetContentAsync puts your HTML string onto the page, just like loading a web page. PdfAsync does the actual printing and saves the file.
The four steps of making a PDF
Steps
Launch
Start hidden Chrome
NewPage
Open a blank tab
SetContent
Load your HTML
PdfAsync
Save the PDF
Filling the template with real data
A "Hello world" PDF is nice, but real reports need real data. Think of an invoice. It needs the customer's name, a list of items, and a total. The wedding-card trick from the start of this guide is exactly what we want: one design, many sets of data.
The cleanest way to do this is to keep your HTML as a template with little blanks in it, then fill those blanks with data. A simple way is to use string replacement, but for anything bigger you will want a real templating tool like Scriban or Handlebars.Net. These let you write loops and if statements right inside your HTML.
Here is the journey your data takes.
Let's see it in code with a simple replace, so the idea is clear. In real projects you would swap the manual replace for a template engine.
using PuppeteerSharp;
// Our data for one invoice.
var customerName = "Priya Sharma";
var total = "₹4,500";
// The template has blanks marked with double braces.
var template = @"
<html>
<head>
<style>
body { font-family: Arial, sans-serif; padding: 40px; }
h1 { color: #1a73e8; }
.total { font-size: 20px; font-weight: bold; }
</style>
</head>
<body>
<h1>Invoice</h1>
<p>Billed to: {{name}}</p>
<p class='total'>Total: {{total}}</p>
</body>
</html>";
// Fill the blanks with real data.
var finalHtml = template
.Replace("{{name}}", customerName)
.Replace("{{total}}", total);
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true });
await using var page = await browser.NewPageAsync();
await page.SetContentAsync(finalHtml);
await page.PdfAsync("invoice.pdf");Notice the CSS inside the <style> tag. That is normal web styling. The blue heading and the bold total will appear in the PDF just as they would in Chrome.
Controlling how the PDF looks
A real report needs more than plain printing. You want a paper size, margins, and sometimes a header and footer with page numbers. PuppeteerSharp gives you all of this through PdfOptions.
Here are the options you will reach for most often.
| Option | What it controls | Example value |
|---|---|---|
Format | Paper size | PaperFormat.A4 |
Landscape | Sideways printing | true |
PrintBackground | Show CSS background colors | true |
MarginOptions | White space around the edges | Top = "20px" |
DisplayHeaderFooter | Turn header and footer on | true |
HeaderTemplate / FooterTemplate | HTML for the header and footer | page numbers |
One small trap to remember: by default Chrome does not print background colors. If your report has a colored header bar, set PrintBackground = true, or it will come out white.
Here is a fuller example with options, including a footer that shows the page number.
var pdfOptions = new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true,
MarginOptions = new MarginOptions
{
Top = "40px",
Bottom = "60px",
Left = "30px",
Right = "30px"
},
DisplayHeaderFooter = true,
FooterTemplate = @"
<div style='font-size:10px; width:100%; text-align:center;'>
Page <span class='pageNumber'></span>
of <span class='totalPages'></span>
</div>"
};
await page.PdfAsync("invoice.pdf", pdfOptions);The footer uses special class names that Chrome understands. The class pageNumber is replaced with the current page, and totalPages is replaced with the total count. You can also use date, title, and url the same way.
Waiting for the page to be ready
Sometimes your HTML loads fonts, images, or charts drawn by JavaScript. If you print too early, those parts may be missing from the PDF. This is a very common beginner mistake.
The fix is to tell PuppeteerSharp to wait until the page is settled before printing.
// Wait until the network is quiet (no requests for 500ms).
// This helps images and web fonts finish loading.
await page.SetContentAsync(finalHtml, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
await page.PdfAsync("report.pdf");Networkidle0 means "wait until there have been no network requests for a short while." It is a simple, safe choice for most reports. PuppeteerSharp also waits for fonts to be ready by default, which helps your text look correct.
Make the PDF reliable
Steps
SetContent
Load the HTML
WaitIdle
Let images and fonts load
PdfAsync
Print only when ready
Return file
Send bytes to the user
Returning the PDF from an API
In most apps you will not save the PDF to disk. Instead, you make it in memory and send it straight back to the user as a download. PuppeteerSharp can give you the PDF as a byte array, which fits perfectly into an ASP.NET Core endpoint.
app.MapGet("/invoice/{id}", async (int id) =>
{
var finalHtml = BuildInvoiceHtml(id); // your template + data
await using var browser = await Puppeteer.LaunchAsync(
new LaunchOptions { Headless = true });
await using var page = await browser.NewPageAsync();
await page.SetContentAsync(finalHtml);
// Get the PDF as bytes instead of saving to a file.
var pdfBytes = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true
});
// Send it back as a downloadable file.
return Results.File(pdfBytes, "application/pdf", $"invoice-{id}.pdf");
});Now a request to GET /invoice/{id} returns a ready PDF. The browser made it, your code never touched a single drawing command, and the user gets a clean file.
The whole picture
Let's tie every piece together. Here is the full life of a single report request, from the click to the download.
Every report you ever build follows this same shape. Once you understand these arrows, you can make invoices, salary slips, certificates, tickets, or monthly reports, all with the same small set of steps.
Performance: launch once, reuse many times
Starting a browser takes time, maybe a second or two. If you launch a fresh browser for every single PDF, your app will feel slow under load.
The smart move is to launch the browser once and keep it alive, then open and close cheap pages for each report. A common pattern is to wrap the browser in a singleton service that the rest of your app borrows from.
Here are the rules of thumb to keep things fast and stable:
- Launch the browser once at startup, not per request.
- Open a new page per report, and close it right after with
await using. - For very high volume, run PDF work as a background job so web requests stay quick.
- In containers, install the needed system libraries so Chromium can start.
A small warning: each open page uses memory. Always close pages when you are done. The await using keyword in the examples does this for you automatically, which is why we use it.
Running inside Docker
Many .NET apps run inside Docker containers. Chromium needs a handful of Linux system libraries to start, and a slim container image does not include them by default. If you forget them, you will see an error when the browser tries to launch.
The fix is to add those libraries in your Dockerfile, or to use a base image that already has them. The PuppeteerSharp docs list the exact packages. You can also point PuppeteerSharp at a Chromium you installed through the system package manager, instead of downloading one. Either path works; just pick one and stay consistent.
Common mistakes to avoid
A few traps catch almost every beginner. Knowing them now will save you hours later.
- Forgetting
PrintBackground = true. Colored bars and shaded rows vanish without it. - Printing before content loads. Images and charts go missing. Wait for the page to settle.
- Launching a browser per request. This is slow. Reuse one browser.
- Not closing pages. Memory creeps up over time. Always dispose pages.
- Missing Docker libraries. The browser refuses to start. Add the listed packages.
Quick recap
- PuppeteerSharp controls a hidden Chrome browser from your C# code, and it is free and open source.
- You build your report as an HTML template with CSS, which is easy because you already know the web.
- Fill the template with real data using string replace for small jobs, or a template engine like Scriban for bigger ones.
- Call
SetContentAsyncto load the HTML, thenPdfAsyncorPdfDataAsyncto print it. - Use
PdfOptionsto set the paper size, margins, headers, and footers, and rememberPrintBackground = truefor colors. - Wait for the page to settle so images and fonts are not missing.
- For speed, launch the browser once and reuse it; close each page when done.
- In Docker, add the system libraries Chromium needs so it can start.
With these pieces you can build almost any business document in .NET, for free, using skills you already have.
References and further reading
- PDF Reporting in .NET With HTML Templates and PuppeteerSharp (Milan Jovanović)
- PuppeteerSharp API Reference: PdfOptions
- PuppeteerSharp official site and docs
- PDF Generation with Puppeteer Sharp (Auth0 Blog)
- HTML to PDF in C# with PuppeteerSharp (PDFBolt)
Related Posts
Flexible PDF Reporting in .NET Using Razor Views
A beginner-friendly guide to making PDF reports in .NET by writing Razor views as HTML and turning them into PDFs with a headless browser.
How to Build a Production-Ready Invoice Builder in .NET Using IronPDF
A simple, beginner-friendly guide to building a real invoice PDF generator in .NET with IronPDF, from HTML template to a clean download in your API.
Building Semantic Search With Amazon S3 Vectors and Semantic Kernel
A beginner-friendly guide to building semantic search in .NET using Amazon S3 Vectors for cheap storage and Semantic Kernel for embeddings.
What Is Vector Search? A Concise Guide for .NET Developers
A simple, friendly guide to vector search for .NET developers: embeddings, similarity, nearest neighbors, and how to build it with Microsoft.Extensions.VectorData.
How I Implemented Full-Text Search on My Website with EF Core
A simple, beginner-friendly guide to adding fast full-text search to your .NET website using EF Core with SQL Server and PostgreSQL.
How to Scale Long-Running API Requests in .NET: A Beginner's Guide
Learn how to handle slow, long-running API requests in .NET using the 202 Accepted pattern, background services, channels, and status polling.