Generating PDFs in .NET comes down to one question: do you already have the document as HTML, or are you building the layout from scratch in code? If your content is HTML and CSS, drive headless Chromium with Playwright for .NET, or skip the browser entirely and call a PDF API. If you want a pure-C# layout with no markup, QuestPDF is the modern default.
This guide covers the realistic .NET landscape with working C# code for each path, and a clear breakdown of when to switch.
What are the options for generating PDFs in C#?
Here is a direct comparison of the main .NET PDF approaches before any code.
| Library | Engine | HTML/CSS support | Hosting cost | Best for |
|---|---|---|---|---|
| QuestPDF | Custom C# layout engine (SkiaSharp) | None (fluent C# API) | In-process, no browser | Code-defined documents, full control in C# |
| Playwright for .NET | Headless Chromium | Full (modern CSS) | Chromium binary (~300 MB) | Pixel-accurate HTML rendering, self-hosted |
| PuppeteerSharp | Headless Chromium | Full (modern CSS) | Chromium binary (~300 MB) | Existing Puppeteer-style code |
| DinkToPdf / wkhtmltopdf wrappers | wkhtmltopdf (archived WebKit) | Poor (old WebKit) | Native binary, fragile | Legacy projects only, avoid for new work |
| PDF REST API (PDF4.dev) | Hosted Chromium | Full (managed) | HttpClient only, no browser | Production, serverless, teams |
Note: the wkhtmltopdf project was archived in 2023 and is no longer maintained. The .NET wrappers around it (DinkToPdf, WkHtmlToPdf-DotNet) inherit that frozen engine. Do not pick them for a new project.
A quick rule of thumb: choose QuestPDF when you control every element in C# and do not need HTML. Choose a Chromium-based renderer (Playwright for .NET, PuppeteerSharp, or a PDF API) when your content is already HTML and CSS, or when the design is complex enough that you want a real browser to lay it out.
Option 1: QuestPDF (fluent C# layout)
QuestPDF is an open-source .NET library that builds PDFs with a fluent C# API on top of SkiaSharp. There is no HTML and no CSS: you describe the layout directly in code, which gives precise control and predictable output.
Installation
dotnet add package QuestPDFA basic invoice
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
QuestPDF.Settings.License = LicenseType.Community;
Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(2, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(12).FontFamily("Arial"));
page.Header().Text("Invoice INV-0042").FontSize(20).Bold();
page.Content().PaddingVertical(20).Column(col =>
{
col.Item().Text("Client: Acme Corp");
col.Item().Text("Total: $1,500.00").Bold();
});
page.Footer().AlignCenter().Text(text =>
{
text.Span("Page ");
text.CurrentPageNumber();
});
});
})
.GeneratePdf("invoice.pdf");License note (read before shipping)
QuestPDF uses a dual license. The Community license is free for individuals and for companies whose annual gross revenue is under 1 million USD. Above that threshold, a paid Professional or Enterprise license applies. The license terms are stated on the QuestPDF licensing page and you must set QuestPDF.Settings.License explicitly. Confirm the current terms before adding it to a commercial product.
When QuestPDF fits
QuestPDF is a strong choice when the document is defined in code and you do not have existing HTML. The tradeoff is that you cannot reuse an HTML email, an invoice template designed by a marketer, or a styled web page. Every element is C#. If your source of truth is already HTML and CSS, the next options reuse that markup directly.
Option 2: Playwright for .NET (headless Chromium)
Playwright for .NET is Microsoft's browser automation library. It drives a real headless Chromium, so it supports the full modern CSS spec: Flexbox, Grid, custom properties, @font-face, and web fonts. If it renders in Chrome, it renders in the PDF.
Installation
dotnet add package Microsoft.Playwright
# after building once, install the browser binaries:
pwsh bin/Debug/net8.0/playwright.ps1 install chromiumRender HTML to PDF
using Microsoft.Playwright;
async Task<byte[]> HtmlToPdfAsync(string html)
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();
await page.SetContentAsync(html, new PageSetContentOptions
{
WaitUntil = WaitUntilState.NetworkIdle
});
var pdf = await page.PdfAsync(new PagePdfOptions
{
Format = "A4",
PrintBackground = true,
Margin = new Margin
{
Top = "20mm",
Bottom = "20mm",
Left = "15mm",
Right = "15mm"
}
});
await browser.CloseAsync();
return pdf;
}
var html = "<html><body><h1>Hello PDF</h1></body></html>";
byte[] bytes = await HtmlToPdfAsync(html);
await File.WriteAllBytesAsync("output.pdf", bytes);Reuse the browser in production
Launching a fresh Chromium per request adds 1 to 3 seconds of startup time. In an ASP.NET Core app, register Playwright as a singleton so one browser instance serves every request.
public sealed class PdfRenderer : IAsyncDisposable
{
private readonly Task<IBrowser> _browser;
public PdfRenderer()
{
_browser = InitAsync();
}
private static async Task<IBrowser> InitAsync()
{
var playwright = await Playwright.CreateAsync();
return await playwright.Chromium.LaunchAsync();
}
public async Task<byte[]> RenderAsync(string html)
{
var browser = await _browser;
var page = await browser.NewPageAsync();
await page.SetContentAsync(html, new PageSetContentOptions
{
WaitUntil = WaitUntilState.Load
});
var pdf = await page.PdfAsync(new PagePdfOptions
{
Format = "A4",
PrintBackground = true
});
await page.CloseAsync();
return pdf;
}
public async ValueTask DisposeAsync()
{
var browser = await _browser;
await browser.DisposeAsync();
}
}Register it once in Program.cs:
builder.Services.AddSingleton<PdfRenderer>();With a warm singleton, renders settle around 200 to 300ms per A4 page.
Option 3: PuppeteerSharp
PuppeteerSharp is a .NET port of Puppeteer that also drives headless Chromium. The output matches Playwright closely because both use the same engine. It is a good fit when you already have Puppeteer-style code or examples to port.
dotnet add package PuppeteerSharpusing PuppeteerSharp;
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><body><h1>Invoice INV-0042</h1></body></html>");
byte[] pdf = await page.PdfDataAsync(new PdfOptions
{
Format = PuppeteerSharp.Media.PaperFormat.A4,
PrintBackground = true
});
await File.WriteAllBytesAsync("invoice.pdf", pdf);BrowserFetcher().DownloadAsync() pulls a Chromium build (~300 MB) on first run. Do this at build time or in your container image, not on the first user request.
When the self-hosted approach starts to hurt
QuestPDF, Playwright for .NET, and PuppeteerSharp all work well at low to medium volume. At production scale, the Chromium-based options surface the same pain points.
Chromium hosting pain points
Image size. A .NET image with Playwright or PuppeteerSharp plus Chromium and its native dependencies adds roughly 300 to 550 MB. That inflates build times, registry storage, and cold starts.
System dependencies. Headless Chromium needs a set of native Linux libraries (libnss3, libatk, libgbm, and more). Every deployment target (Docker, Azure, CI) needs them installed, and a missing library produces a cryptic launch failure.
Concurrency management. Under load you must manage a browser pool, the page lifecycle, and graceful shutdown. Unclosed pages leak memory, which is a common production incident.
Serverless friction. Azure Functions and AWS Lambda packages get awkward fast when a 300 MB Chromium has to fit inside size limits. You end up with a custom container image to maintain.
The break-even point
The table below shows when the operational cost of self-hosted PDF generation stops being worth it.
| Signal | Self-hosted (QuestPDF / Playwright) | REST API (PDF4.dev) |
|---|---|---|
| PDFs per day | Under 500 | Any volume |
| Source format | C# code or HTML | HTML/CSS |
| Deployment target | Traditional server | Serverless, Azure Functions, Lambda |
| Image size budget | No constraint | Size-constrained |
| CSS complexity | Simple to moderate | Complex HTML/CSS |
| Concurrency | DIY pool | Handled by API |
| Designer edits templates | Code change | Edit in a UI |
Option 4: PDF REST API (PDF4.dev) from C#
PDF4.dev is an HTML-to-PDF REST API. You POST your HTML and data, and get a PDF back. Rendering runs on a managed Chromium pool, so there is no browser binary, no native dependencies, and no concurrency pool to build on your side. From C# it is one HttpClient call, which works the same on a VM, in a container, in Azure Functions, or in AWS Lambda.
You can preview a template in the browser first with the HTML to PDF toolTry it free before writing any code.
Render with raw HTML
The API uses Handlebars syntax for variables, so a field like {{invoice_number}} in the HTML is filled from the data object.
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
var apiKey = "p4_live_your_api_key";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
var html = @"
<html>
<head>
<style>
body { font-family: Inter, sans-serif; margin: 20mm; }
h1 { color: #111; }
</style>
</head>
<body>
<h1>Invoice {{invoice_number}}</h1>
<p>Client: {{client_name}}</p>
<p>Total: {{total}}</p>
</body>
</html>";
var payload = new
{
html,
data = new
{
invoice_number = "INV-0042",
client_name = "Acme Corp",
total = "$1,700.00"
}
};
var response = await http.PostAsJsonAsync(
"https://pdf4.dev/api/v1/render", payload);
response.EnsureSuccessStatusCode();
byte[] pdf = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync("invoice.pdf", pdf);Use a saved template
You can save HTML templates in the dashboard and reference them by slug. This keeps document design out of your C# code, so a designer can edit the template without a redeploy.
var payload = new
{
template_id = "invoice",
data = new
{
invoice_number = "INV-0042",
client_name = "Acme Corp",
items = new[]
{
new { description = "Consulting", qty = 10, unit_price = 150 },
new { description = "Setup fee", qty = 1, unit_price = 200 }
},
total = 1700
}
};
var response = await http.PostAsJsonAsync(
"https://pdf4.dev/api/v1/render", payload);
response.EnsureSuccessStatusCode();
byte[] pdf = await response.Content.ReadAsByteArrayAsync();ASP.NET Core controller
Wire the call into a controller and return the bytes with the application/pdf content type. The API key comes from configuration, never from client code.
using Microsoft.AspNetCore.Mvc;
using System.Net.Http.Headers;
using System.Net.Http.Json;
[ApiController]
[Route("invoices")]
public class InvoiceController : ControllerBase
{
private readonly IHttpClientFactory _factory;
private readonly string _apiKey;
public InvoiceController(IHttpClientFactory factory, IConfiguration config)
{
_factory = factory;
_apiKey = config["Pdf4:ApiKey"]!;
}
[HttpGet("{id}.pdf")]
public async Task<IActionResult> GetInvoicePdf(string id)
{
var http = _factory.CreateClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _apiKey);
var payload = new
{
template_id = "invoice",
data = new
{
invoice_number = id,
client_name = "Acme Corp",
total = 1700
}
};
var response = await http.PostAsJsonAsync(
"https://pdf4.dev/api/v1/render", payload);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
return StatusCode((int)response.StatusCode, error);
}
byte[] pdf = await response.Content.ReadAsByteArrayAsync();
return File(pdf, "application/pdf", $"invoice-{id}.pdf");
}
}Register IHttpClientFactory once in Program.cs:
builder.Services.AddHttpClient();No Chromium, no native libraries, no image bloat. The endpoint works the same on any .NET host.
Comparison: which method should you choose?
Use this table to pick by requirement.
| Requirement | Best approach |
|---|---|
| Layout defined entirely in C#, no HTML | QuestPDF |
| Reuse existing HTML and CSS (emails, web pages) | PDF API or Playwright for .NET |
| Pixel-accurate complex CSS, self-hosted | Playwright for .NET or PuppeteerSharp |
| Deploy to Azure Functions or AWS Lambda | PDF API |
| Smallest container image | PDF API |
| High concurrency without building a pool | PDF API |
| Designers edit templates without a redeploy | PDF API |
| Avoid third-party licensing terms | Playwright for .NET (MIT) or a PDF API |
Handling the API key and configuration in ASP.NET Core
Never put a PDF API key in client code or commit it to source control. In ASP.NET Core, store it in user secrets during development and in environment variables in production.
# local development
dotnet user-secrets set "Pdf4:ApiKey" "p4_live_your_key_here"Read it through IConfiguration as shown in the controller above. In production, set the same key as an environment variable (Pdf4__ApiKey) on your host (Azure App Service, container platform, or VM). The double underscore maps to the nested configuration key.
Fonts and page formats in .NET PDF generation
Font handling differs by approach, and it is a frequent source of dev-versus-production differences.
QuestPDF uses fonts registered through SkiaSharp. Bundle a font file in your project and register it with FontManager.RegisterFont(stream) so the same glyphs render on every host, including minimal Linux containers that ship without system fonts.
Playwright for .NET and PuppeteerSharp load fonts the way a browser does: @font-face with a URL, or Google Fonts. Wait for NetworkIdle when setting content so web fonts finish loading before the PDF is captured.
PDF4.dev supports Google Fonts through the google_fonts_url format field and @font-face with any URL, so fonts resolve server-side without shipping font files in your build.
All four support A4, Letter, and custom page sizes. With the API, the page format is set through format.preset ("a4", "a4-landscape", "letter", or "custom" with explicit width and height).
Summary
.NET has a clear split. QuestPDF is the strongest pure-C# library when you build the document in code and the dual license fits your revenue. Playwright for .NET and PuppeteerSharp render real HTML and CSS through headless Chromium when you want a browser to do the layout. The archived wkhtmltopdf wrappers should stay out of new projects.
When you do not want to host a browser, hit Chromium size limits in serverless, or want designers editing templates in a UI, a PDF API turns the whole problem into a single HttpClient call.
Try a template in the browser with the HTML to PDF tool, or see the cross-language HTML to PDF benchmark 2026 for performance numbers. For the JavaScript side of the same problem, see generating PDFs from HTML in Node.js and the Python guide.
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.


