How to generate PDF Reports In .NET

Introduction
PDF generation in .NET used to mean either wrestling with a low-level drawing API or shelling out to a headless browser to print HTML. QuestPDF is a different approach: a fluent, code-first layout API where you compose a document out of pages, rows, columns, and tables, and it handles pagination, page breaks, and measurement for you.
This video generates an employee list report - a titled header with a company logo, a data table with alternating row shading and currency formatting, and a footer with page numbers - and returns it from an ASP.NET Core minimal API endpoint as a downloadable file. The solution is Clean Architecture on .NET 9 with the report generation living in the Application layer.
🎬 Watch the full video here:
The licensing bit, up front
QuestPDF is free under the Community license for individuals and companies under roughly 1M USD annual revenue; larger companies need a paid license. You must set it explicitly at startup or it throws:
QuestPDF.Settings.License = LicenseType.Community;
The demo does this in the Application layer's DI registration. Check the current terms against your situation before shipping.
Composing a document
A QuestPDF document is Document.Create(container => ...). Inside, you define a page and give it a header, content, and footer:
Document.Create(container =>
{
container.Page(page =>
{
page.Margin(30);
page.Header().Row(row => { /* title, generated date, logo */ });
page.Content().Table(table => { /* columns, header, rows */ });
page.Footer().AlignCenter().Text(text =>
{
text.Span("Page "); text.CurrentPageNumber();
text.Span(" of "); text.TotalPages();
});
});
}).GeneratePdf(); // returns byte[]
The whole layout is expressions. There is no template file, no separate markup language - the report is C#, so it is refactorable, testable, and diffable like any other code.
The header with an optional logo
The header is a Row with two items: a column holding the report title and a Generated: {timestamp} line, and - only if a logo was supplied - a fixed-width image on the right.
if (logo != null)
row.ConstantItem(80).AlignMiddle().Image(logo);
The logo is passed into the service as byte[]?. The endpoint reads it from wwwroot/logo.png if the file exists and passes null otherwise, so the report renders fine with or without branding.
The data table
page.Content().Table(...) takes a column definition (ConstantColumn for fixed widths like the salary column, RelativeColumn for flexible ones like name and department), a bold header row, and then a cell per field per employee. QuestPDF paginates the table automatically - if the employee list runs to three pages, the header row repeats and the footer numbers stay correct with no extra work.
Two nice touches in the demo: alternating row background colour (rowIndex % 2 == 0 ? Colors.Grey.Lighten3 : Colors.White) for readability, and $"{employee.Salary:C}" for locale-aware currency formatting. A shared CellStyle helper keeps padding consistent across every cell.
Serving it from the endpoint
The service returns byte[]; the endpoint hands it back as a file:
app.MapGet("/employee-list-report",
async (IEmployeeListPdfGeneratorService generator, IWebHostEnvironment env) =>
{
var pdf = await generator.GenerateEmployeeListPdfAsync(logoBytes);
return Results.File(pdf, "application/pdf", "EmployeeList.pdf");
});
Results.File with the application/pdf content type and a filename triggers a download in the browser. Return the same bytes as an email attachment or write them to blob storage - the generator does not care what happens to the array.
Where report generation belongs
Putting it in the Application layer behind IEmployeeListPdfGeneratorService keeps the API layer thin and makes the report independently testable - you can assert on the byte length, or render it in a test and eyeball it, without spinning up the web host. The service depends on a repository for the data, not on DbContext directly.
Common pitfalls
- Forgetting the license line. QuestPDF throws on first use without
QuestPDF.Settings.License. - Generating large reports synchronously in the request. A 10,000-row PDF blocks the request thread and the socket. For big reports, generate in a background job and give the user a link when it is ready.
- Building HTML and converting it. With QuestPDF you compose directly; reaching for an HTML-to-PDF step reintroduces the browser dependency this library removes.
- Hardcoding the logo path. Use
IWebHostEnvironmentand tolerate the file being absent. - Assuming culture.
:Cformats using the server's current culture - set it explicitly if the report must always be one currency.
Key Takeaways
- QuestPDF is a fluent, code-first PDF API - documents are C# expressions, not templates, so they are testable and refactorable.
- Set
QuestPDF.Settings.License = LicenseType.Communityat startup and confirm the licensing terms fit your company. - Compose a page from
Header,Content, andFooter; QuestPDF handles pagination, repeating table headers, and page numbers. GeneratePdf()returnsbyte[]; serve it withResults.File(pdf, "application/pdf", "name.pdf").- Keep generation in the Application layer behind an interface so it is thin at the API and testable in isolation.
- Move large report generation to a background job rather than blocking the request.
Get the Full Source Code
The complete runnable solution - the report service with the header, table, and footer composition, the repository, and the download endpoint - is available to Patreon supporters. If you want to hit the endpoint and get a real PDF instead of rebuilding it from the walkthrough above, you can find it on Patreon.