Back to blog
Backend Systemsintermediate

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.

LearnixoApril 15, 20264 min read
.NETC#PDFExcelQuestPDFClosedXMLASP.NET Core
Share:𝕏

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
C#
// Program.cs — required license setting
QuestPDF.Settings.License = LicenseType.Community; // free for revenue < $1M

Document Structure

Every QuestPDF document has Page → Column → Row/Table composition.

C#
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

C#
[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:

C#
// 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 ClosedXML
C#
public 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:

C#
// 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

C#
[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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.