Skip to main content
SEMastery
Fundamentalsbeginner

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.

12 min readUpdated November 28, 2025

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.

The basic idea: your data becomes HTML, and a hidden browser prints it.

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.

ApproachHow you build itGood forPain points
HTML to PDF (PuppeteerSharp)Write HTML and CSSInvoices, reports, certificatesNeeds a browser running
Draw by hand (low-level library)Place text and lines by coordinatesTiny labels, simple receiptsHard 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:

  1. Launch the browser.
  2. Open a page.
  3. Set the HTML.
  4. 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

Launch
NewPage
SetContent
PdfAsync

Steps

1

Launch

Start hidden Chrome

2

NewPage

Open a blank tab

3

SetContent

Load your HTML

4

PdfAsync

Save the PDF

Each call to PuppeteerSharp does one small job.

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.

Data flows from your C# model into the template, then into the PDF.

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.

OptionWhat it controlsExample value
FormatPaper sizePaperFormat.A4
LandscapeSideways printingtrue
PrintBackgroundShow CSS background colorstrue
MarginOptionsWhite space around the edgesTop = "20px"
DisplayHeaderFooterTurn header and footer ontrue
HeaderTemplate / FooterTemplateHTML for the header and footerpage 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

SetContent
WaitIdle
PdfAsync
Return file

Steps

1

SetContent

Load the HTML

2

WaitIdle

Let images and fonts load

3

PdfAsync

Print only when ready

4

Return file

Send bytes to the user

A safe order of steps so nothing is missing from the output.

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.

The complete journey of a PDF request in a web app.

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 SetContentAsync to load the HTML, then PdfAsync or PdfDataAsync to print it.
  • Use PdfOptions to set the paper size, margins, headers, and footers, and remember PrintBackground = true for 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

Related Posts