Generate PDFs and Excel Files in .NET
QuestPDF for PDFs with a fluent C# API, ClosedXML for Excel workbooks with styling and auto-fit, streaming large files, and returning them from ASP.NET Core endpoints.
QuestPDF — PDFs Without Adobe or Word
QuestPDF (MIT licensed) generates PDFs entirely in C# with a fluent builder API. No Word, no Acrobat, no interop.
dotnet add package QuestPDF// Program.cs — required license setting
QuestPDF.Settings.License = LicenseType.Community; // free for revenue < $1MDocument Structure
Every QuestPDF document has Page → Column → Row/Table composition.
public class InvoiceDocument : IDocument
{
private readonly InvoiceModel _model;
public InvoiceDocument(InvoiceModel model) => _model = model;
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
public void Compose(IDocumentContainer container)
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.DefaultTextStyle(x => x.FontSize(11));
page.Header().Element(ComposeHeader);
page.Content().Element(ComposeContent);
page.Footer().AlignCenter().Text(t =>
{
t.Span("Page ");
t.CurrentPageNumber();
t.Span(" of ");
t.TotalPages();
});
});
}
void ComposeHeader(IContainer c) =>
c.Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text($"Invoice #{_model.InvoiceNumber}")
.Bold().FontSize(20);
col.Item().Text($"Date: {_model.IssueDate:d}");
});
row.ConstantItem(120).Image(_model.LogoBytes);
});
void ComposeContent(IContainer c) =>
c.Column(col =>
{
col.Item().PaddingVertical(10).LineHorizontal(1).LineColor(Colors.Grey.Medium);
col.Item().Element(ComposeTable);
col.Item().AlignRight().Text($"Total: {_model.Total:C}").Bold().FontSize(14);
});
void ComposeTable(IContainer c) =>
c.Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3); // Description
cols.RelativeColumn(1); // Qty
cols.RelativeColumn(1); // Price
cols.RelativeColumn(1); // Total
});
// Header row
static IContainer HeaderCell(IContainer cell) =>
cell.Background(Colors.Grey.Lighten2).Padding(5);
table.Header(h =>
{
h.Cell().Element(HeaderCell).Text("Description").Bold();
h.Cell().Element(HeaderCell).Text("Qty").Bold();
h.Cell().Element(HeaderCell).Text("Price").Bold();
h.Cell().Element(HeaderCell).Text("Total").Bold();
});
foreach (var item in _model.Items)
{
table.Cell().Padding(5).Text(item.Description);
table.Cell().Padding(5).AlignCenter().Text(item.Quantity.ToString());
table.Cell().Padding(5).AlignRight().Text(item.UnitPrice.ToString("C"));
table.Cell().Padding(5).AlignRight().Text((item.Quantity * item.UnitPrice).ToString("C"));
}
});
}Generating and Returning the PDF
[ApiController]
[Route("api/invoices")]
public class InvoicesController : ControllerBase
{
private readonly IInvoiceService _invoices;
public InvoicesController(IInvoiceService invoices) => _invoices = invoices;
[HttpGet("{id}/pdf")]
public async Task<IActionResult> GetPdf(Guid id)
{
var model = await _invoices.GetModelAsync(id);
if (model is null) return NotFound();
var document = new InvoiceDocument(model);
var bytes = document.GeneratePdf(); // synchronous, in-memory
return File(bytes, "application/pdf", $"invoice-{model.InvoiceNumber}.pdf");
}
[HttpGet("{id}/pdf/stream")]
public async Task<IActionResult> GetPdfStream(Guid id)
{
var model = await _invoices.GetModelAsync(id);
if (model is null) return NotFound();
// Stream directly to response — no full byte array in memory
Response.ContentType = "application/pdf";
Response.Headers.ContentDisposition = $"attachment; filename=\"invoice-{model.InvoiceNumber}.pdf\"";
new InvoiceDocument(model).GeneratePdf(Response.Body);
return new EmptyResult();
}
}iTextSharp as an Alternative
For complex documents with existing PDF templates, iTextSharp (AGPL or commercial) lets you fill AcroForm fields:
// dotnet add package itext7
using iText.Kernel.Pdf;
using iText.Forms;
public byte[] FillTemplate(string templatePath, Dictionary<string, string> fields)
{
using var ms = new MemoryStream();
using var reader = new PdfReader(templatePath);
using var writer = new PdfWriter(ms);
using var pdf = new PdfDocument(reader, writer);
var form = PdfAcroForm.GetAcroForm(pdf, false);
foreach (var (key, value) in fields)
form.GetField(key)?.SetValue(value);
form.FlattenFields();
return ms.ToArray();
}ClosedXML — Excel Without COM
dotnet add package ClosedXMLpublic byte[] GenerateOrdersReport(IEnumerable<Order> orders)
{
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Orders");
// Header row
var headers = new[] { "Order ID", "Customer", "Date", "Status", "Total" };
for (int i = 0; i < headers.Length; i++)
{
var cell = ws.Cell(1, i + 1);
cell.Value = headers[i];
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#4472C4");
cell.Style.Font.FontColor = XLColor.White;
}
// Data rows
int row = 2;
foreach (var o in orders)
{
ws.Cell(row, 1).Value = o.Id.ToString();
ws.Cell(row, 2).Value = o.CustomerName;
ws.Cell(row, 3).Value = o.CreatedAt;
ws.Cell(row, 3).Style.DateFormat.Format = "yyyy-MM-dd";
ws.Cell(row, 4).Value = o.Status.ToString();
ws.Cell(row, 5).Value = o.Total;
ws.Cell(row, 5).Style.NumberFormat.Format = "$#,##0.00";
// Alternate row shading
if (row % 2 == 0)
ws.Row(row).Style.Fill.BackgroundColor = XLColor.FromHtml("#F2F2F2");
row++;
}
// Auto-fit all columns
ws.Columns().AdjustToContents();
// Freeze header row
ws.SheetView.FreezeRows(1);
// Add totals row
ws.Cell(row, 4).Value = "Grand Total";
ws.Cell(row, 4).Style.Font.Bold = true;
ws.Cell(row, 5).FormulaA1 = $"=SUM(E2:E{row - 1})";
ws.Cell(row, 5).Style.Font.Bold = true;
ws.Cell(row, 5).Style.NumberFormat.Format = "$#,##0.00";
using var ms = new MemoryStream();
workbook.SaveAs(ms);
return ms.ToArray();
}Streaming Large Excel Files
For 100k+ row exports, stream rows without building the full workbook in memory using EPPlus or SpreadsheetLight:
// dotnet add package EPPlus (commercial license for production)
[HttpGet("export/large")]
public IActionResult ExportLarge()
{
Response.ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
Response.Headers.ContentDisposition = "attachment; filename=export.xlsx";
using var package = new ExcelPackage(Response.Body);
var ws = package.Workbook.Worksheets.Add("Data");
ws.Cells[1, 1].Value = "ID";
ws.Cells[1, 2].Value = "Value";
// Simulate large dataset — replace with DB streaming (IAsyncEnumerable)
for (int i = 1; i <= 100_000; i++)
{
ws.Cells[i + 1, 1].Value = i;
ws.Cells[i + 1, 2].Value = $"Row {i}";
}
package.Save(); // writes directly to Response.Body
return new EmptyResult();
}Returning Files From an Endpoint
[HttpGet("orders/excel")]
public async Task<IActionResult> OrdersExcel([FromQuery] DateOnly from, [FromQuery] DateOnly to)
{
var orders = await _db.Orders
.Where(o => o.CreatedAt >= from.ToDateTime(TimeOnly.MinValue)
&& o.CreatedAt <= to.ToDateTime(TimeOnly.MaxValue))
.AsNoTracking()
.ToListAsync();
var bytes = _reportService.GenerateOrdersReport(orders);
var fileName = $"orders-{from:yyyyMMdd}-{to:yyyyMMdd}.xlsx";
return File(bytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileName);
}For very large reports, kick off a background job, store the result in Blob Storage, and return a download URL via SignalR or polling — never block the request thread for minutes.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.