Skip to main content
SEMastery
Fundamentalsbeginner

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.

12 min readUpdated October 21, 2025

Think about your school report card. The teacher does not redraw the whole card by hand for every student. There is one printed template with boxes for your name, your marks, and your grade. The teacher just fills in the blanks for each child. Same shape, different numbers.

Making PDF reports in .NET works best the same way. You design the report once as a template. Then, for each invoice or each student, you pour in fresh data and print. In .NET, the nicest way to write that template is a Razor view, because it is just HTML with a little C# sprinkled in.

In this guide you will learn a simple and flexible way to make PDFs in .NET: write a Razor view, turn it into HTML, then let a small headless browser print that HTML into a PDF. We will keep the words plain and the steps small.

Why PDFs are still everywhere

A PDF looks the same on every phone, laptop, and printer. An invoice, a salary slip, a ticket, a certificate, a bank statement, a GST bill, the marksheet your college emails you. All of these need to look exactly right and never shift around. That is the job a PDF does well.

So almost every real business app has to make PDFs at some point. The question is only how you make them.

The two big ways to make a PDF

There are two main styles in the .NET world. It helps to see them side by side before we pick one.

ApproachHow it worksGood forNot great for
Draw by codeYou place text, lines, and boxes at exact positions in C#Tiny files, full control, no browser neededComplex layouts get painful to write
HTML to PDFYou write HTML and CSS, then a tool prints it to PDFRich layouts, tables, styling, reuse of web skillsNeeds a rendering engine to be present

Drawing by code (with libraries like QuestPDF) is great when the layout is simple and you want zero extra moving parts. But once your report has tables, page breaks, headers, logos, and lots of styling, writing all of that as code gets tiring fast.

The HTML to PDF path is friendlier. You already know HTML and CSS, or you can learn them quickly. You design the report like a web page, and the computer turns that page into a PDF. That is the approach we focus on here, and Razor is what makes the HTML part lovely.

Two common ways to build a PDF in .NET.

Where Razor fits in

A Razor view is a file (ending in .cshtml) that mixes HTML with small bits of C#. You may have seen it in ASP.NET Core web pages. The C# bits start with @.

Here Razor is perfect because a report is mostly fixed HTML with a few spots that change per item. The student's name changes. The total amount changes. The list of line items changes. Razor handles all of that with loops and simple expressions.

The plan has three clear steps:

  1. Render a Razor view into an HTML string, using your data model.
  2. Hand that HTML to a headless browser.
  3. The browser "prints" the page to a PDF and gives you the bytes.

From data to PDF

Model
HTML
PDF

Steps

1

Model

Your data object

2

Render Razor

View becomes HTML

3

Print

Browser makes PDF

The three stages our report passes through.

Step 1: Design the Razor template

Let us build a simple invoice. First we need a model. A model is just a plain C# class that holds the data the report will show.

public sealed class InvoiceModel
{
    public string Number { get; set; } = "";
    public string CustomerName { get; set; } = "";
    public DateOnly Date { get; set; }
    public List<LineItem> Items { get; set; } = new();
 
    public decimal Total => Items.Sum(i => i.Price * i.Quantity);
}
 
public sealed class LineItem
{
    public string Description { get; set; } = "";
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

Now the Razor view. Notice how it is almost all HTML. The @ parts are where data flows in. The @foreach loop builds one table row per item, so the table grows or shrinks with the data on its own.

@model InvoiceModel
 
<html>
<head>
  <style>
    body { font-family: Arial, sans-serif; color: #222; }
    h1 { color: #1a56db; }
    table { width: 100%; border-collapse: collapse; }
    th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
    .total { font-weight: bold; }
  </style>
</head>
<body>
  <h1>Invoice @Model.Number</h1>
  <p>Customer: @Model.CustomerName</p>
  <p>Date: @Model.Date.ToString("dd MMM yyyy")</p>
 
  <table>
    <thead>
      <tr><th>Item</th><th>Qty</th><th>Price</th></tr>
    </thead>
    <tbody>
      @foreach (var item in Model.Items)
      {
        <tr>
          <td>@item.Description</td>
          <td>@item.Quantity</td>
          <td>@item.Price.ToString("C")</td>
        </tr>
      }
    </tbody>
  </table>
 
  <p class="total">Total: @Model.Total.ToString("C")</p>
</body>
</html>

This is the big win of Razor. Want to add a logo? Add an <img>. Want a colored header? Add CSS. Want to hide a row when a value is zero? Use an @if. You are designing a web page, and web pages are flexible.

Step 2: Turn the Razor view into HTML

Razor views normally render inside a web request. To render one to a plain string (outside the usual page flow), a small helper library makes life easy. A popular free one is RazorLight, which compiles and renders Razor templates to a string for you.

using RazorLight;
 
var engine = new RazorLightEngineBuilder()
    .UseFileSystemProject(Path.Combine(AppContext.BaseDirectory, "Templates"))
    .UseMemoryCachingProvider()
    .Build();
 
string html = await engine.CompileRenderAsync("Invoice.cshtml", model);

After this runs, html holds the full HTML for one invoice, with the data already filled in. The UseMemoryCachingProvider line is important: it remembers the compiled template so the second render is much faster than the first.

If your app is already an ASP.NET Core app, you can instead render views with the built-in view engine. RazorLight is handy when you want PDF generation to work even in a console app or a background worker that has no web pages.

Razor view plus model produces a ready HTML string.

Step 3: Print the HTML to a PDF

Now we have HTML. We need something that understands HTML and CSS perfectly and can "print to PDF." The best tool for that is a real browser engine running with no window, called a headless browser.

Two free, popular choices in .NET are:

  • PuppeteerSharp — drives a headless Chromium browser.
  • Microsoft.Playwright — also drives Chromium (and others), made by the Playwright team.

Both can download a known-good copy of Chromium for you, so you do not have to install Chrome by hand. Here is the PuppeteerSharp version.

using PuppeteerSharp;
 
// One-time: make sure a browser is available.
await new BrowserFetcher().DownloadAsync();
 
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
    Headless = true
});
 
await using var page = await browser.NewPageAsync();
await page.SetContentAsync(html);
 
byte[] pdf = await page.PdfDataAsync(new PdfOptions
{
    Format = PaperFormat.A4,
    PrintBackground = true,
    MarginOptions = new MarginOptions
    {
        Top = "20px", Bottom = "20px", Left = "20px", Right = "20px"
    }
});
 
await File.WriteAllBytesAsync("invoice.pdf", pdf);

That is the whole magic. SetContentAsync loads our HTML into the page. PdfDataAsync prints it and hands back the raw bytes. We then save those bytes to a file (or return them from a web endpoint).

Notice PrintBackground = true. Without it, the browser drops background colors to save ink, just like a real printer would. For a styled report you almost always want it on.

Putting it all together

Here is the full journey one report takes, from a request to a finished file.

Full PDF pipeline

Request
Render
HTML
Browser
PDF

Steps

1

Request

Caller asks for a report

2

Render

Razor fills the template

3

HTML

Full styled markup

4

Browser

Headless print step

5

PDF

Bytes returned

Every stage a report passes through, start to finish.

In an ASP.NET Core API, returning the PDF to the browser is just a few lines. The File helper sets the right content type so the browser knows it is a PDF.

app.MapGet("/invoices/{id}/pdf", async (int id, PdfReportService reports) =>
{
    InvoiceModel model = await reports.LoadInvoiceAsync(id);
    byte[] pdf = await reports.RenderInvoiceAsync(model);
 
    return Results.File(pdf, "application/pdf", $"invoice-{model.Number}.pdf");
});

Make it fast: reuse the browser

The slow part is starting the browser. If you launch a fresh browser for every single PDF, your app will crawl under load and may run out of memory.

The fix is simple: start the browser once and keep it alive. Then open a fresh page for each report and close just that page. Pages are cheap; browsers are not.

What to doWhyCost
Reuse one browser instanceLaunching Chromium is the heavy partBig speed win
Open and close a page per reportPages are light and isolatedSmall and safe
Cache compiled Razor templatesCompiling Razor is slow only the first timeFaster repeat renders
Run bulk PDFs as background jobsKeeps web requests quick for usersBetter user experience

Here is the shape of a small service that keeps one browser and reuses it. It implements IAsyncDisposable so the browser is closed cleanly when the app shuts down.

public sealed class PdfReportService : IAsyncDisposable
{
    private readonly Task<IBrowser> _browser;
 
    public PdfReportService()
    {
        _browser = Puppeteer.LaunchAsync(new LaunchOptions { Headless = true });
    }
 
    public async Task<byte[]> RenderAsync(string html)
    {
        IBrowser browser = await _browser;
        await using IPage page = await browser.NewPageAsync();
        await page.SetContentAsync(html);
        return await page.PdfDataAsync(new PdfOptions { Format = PaperFormat.A4 });
    }
 
    public async ValueTask DisposeAsync()
    {
        IBrowser browser = await _browser;
        await browser.DisposeAsync();
    }
}

Register it as a singleton in your app so the same instance (and the same browser) is shared everywhere.

One shared browser serves many reports, each in its own page.

Common things that trip people up

A few small problems catch almost everyone the first time. Knowing them ahead of time saves hours.

Images and fonts do not show. When you use SetContentAsync, relative paths like images/logo.png have nothing to point at. Use full https:// URLs, or embed images as base64 data URLs right inside the HTML. For custom fonts, link to a hosted font or embed it.

Background colors are missing. As noted, set PrintBackground = true.

It works on my laptop but breaks in Docker. A headless browser needs some system libraries to run. The official PuppeteerSharp and Playwright guides list the exact packages to add to your container image. Add them, and it works.

Page breaks land in odd places. Use CSS print rules. For example, page-break-inside: avoid; on a table row keeps a row from splitting across two pages, and page-break-before: always; starts a new page where you want one.

Money and dates look wrong. PDFs are often read by people in one country, so set the culture when you format. ToString("C") uses the current culture's currency symbol, so make sure that culture is what you expect (for example, set it to en-IN for Indian Rupees).

When to pick code-drawing instead

HTML to PDF is wonderful, but it is not always the right tool. If you are in a small serverless function with a tight memory limit, a full browser may be too heavy. If your report is a plain text receipt with no styling, drawing it by code with a library like QuestPDF is lighter and starts instantly.

A good rule of thumb: if your report looks like a web page, use Razor and a headless browser. If it looks like a form drawn with a ruler, code-drawing may be simpler. Match the tool to the shape of the job.

A quick word on libraries and licenses

Always check the license before you ship.

  • PuppeteerSharp and Microsoft.Playwright are open source and free to use.
  • RazorLight is open source and free.
  • QuestPDF has a free community license for smaller companies, with a paid license above a revenue threshold.
  • Some commercial HTML-to-PDF products (such as IronPDF) are paid but offer extra polish and support.

Pick what fits your budget and your team. For learning and for many production apps, the free combination of Razor plus PuppeteerSharp is more than enough.

References and further reading

Quick recap

  • A PDF report is like a fill-in-the-blank template: same shape, fresh data each time.
  • The flexible path is HTML to PDF: design the report as a web page, then print it.
  • Razor is great for writing that HTML because it mixes plain HTML with small C# loops and values.
  • Render the Razor view to an HTML string (RazorLight makes this easy outside web pages).
  • Use a free headless browser (PuppeteerSharp or Playwright) to print the HTML to PDF bytes.
  • For speed, start the browser once and reuse it, open a fresh page per report, and cache compiled templates.
  • Watch out for missing images, missing background colors, Docker libraries, page breaks, and culture-specific formatting.
  • Use code-drawing (like QuestPDF) when the report is simple or memory is very tight.

Related Posts