Sooner or later, most business apps need the same two things: a report someone can print, send or archive, and a spreadsheet with the same numbers that someone else can sort and filter. If you build them in two separate code paths, they drift apart. A total gets rounded differently, a column goes missing, a month filter behaves slightly differently.

In this tutorial you build a small ASP.NET Core minimal API that generates a monthly sales report on the server and returns it as PDF, XLSX or CSV from one report definition. The report groups orders by customer, prints a subtotal per customer and a grand total, takes the month from the query string, marks the PDF as PDF/A-3b with one extra line, and saves its layout to JSON so you can load it back later.

The report engine is sgcReports .NET, a headless engine with bands, expressions, data sources, pagination and PDF, XLSX, CSV and PNG export. You'll use the free Community package from nuget.org. Every code block below was compiled and run against version 2026.10.0, and the numbers you see in the output were checked against the sample data.

What you need

  • The .NET 8 SDK. The package also targets net10.0, net9.0, net6.0, .NET Framework 4.7.2 and .NET Standard 2.0, but this tutorial uses net8.0.
  • curl, or any HTTP client, to call the endpoints.

Create the project

Start from the empty web template and add the package.

dotnet new web -n SalesReportApi -f net8.0 cd SalesReportApi dotnet add package esegece.sgcReports.Community --version 2026.10.0 

The package brings in esegece.sgcPDF.Community, the PDF library the engine writes with, so there's nothing else to install. It asks for licence acceptance: dotnet add package accepts it silently, while an IDE package manager can show an accept dialog first.

All the code goes into Program.cs. Replace the template's contents as you go. The complete file is at the end of the article.

Step 1: the sample data

A report needs rows. In a real app they come from your database, but a list in memory keeps the focus on the report itself.

At the bottom of Program.cs, add an order record and sixteen sample orders spread over February, March and April 2026:

// Step 1: the order record public record Order(DateTime Date, string Customer, string Product, int Quantity, decimal UnitPrice); public static class SampleData { public static readonly List<Order> Orders = new() { new(new DateTime(2026, 2, 26), "Northwind Traders", "USB-C Dock", 2, 89.90m), new(new DateTime(2026, 2, 27), "Blue Harbor Cafe", "Espresso Beans 1kg", 10, 18.50m), new(new DateTime(2026, 3, 2), "Northwind Traders", "27\" Monitor", 3, 249.00m), new(new DateTime(2026, 3, 3), "Contoso Ltd", "Laptop Stand", 5, 39.95m), new(new DateTime(2026, 3, 5), "Blue Harbor Cafe", "Espresso Beans 1kg", 12, 18.50m), new(new DateTime(2026, 3, 9), "Fabrikam Inc", "Wireless Keyboard", 8, 59.00m), new(new DateTime(2026, 3, 11), "Northwind Traders", "USB-C Dock", 4, 89.90m), new(new DateTime(2026, 3, 12), "Contoso Ltd", "Noise Cancelling Headset", 2, 149.00m), new(new DateTime(2026, 3, 16), "Fabrikam Inc", "Wireless Mouse", 8, 24.99m), new(new DateTime(2026, 3, 18), "Blue Harbor Cafe", "Milk Frother", 1, 129.00m), new(new DateTime(2026, 3, 20), "Northwind Traders", "HDMI Cable 2m", 10, 7.49m), new(new DateTime(2026, 3, 24), "Contoso Ltd", "Laptop Stand", 3, 39.95m), new(new DateTime(2026, 3, 27), "Fabrikam Inc", "27\" Monitor", 2, 249.00m), new(new DateTime(2026, 3, 30), "Blue Harbor Cafe", "Paper Cups (500)", 4, 32.00m), new(new DateTime(2026, 4, 1), "Contoso Ltd", "Wireless Mouse", 6, 24.99m), new(new DateTime(2026, 4, 2), "Northwind Traders", "Laptop Stand", 1, 39.95m), }; } 

At the top of Program.cs, put the usings, one culture line, the app and the orders:

using System.Globalization; using esegece.sgcReports; // display formats (#,##0.00) follow the current culture: pin it so the server locale does not change the output CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo("en-US"); var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); // Step 1: in-memory sample data (in a real app this comes from your database) var orders = SampleData.Orders; 

The culture line is the first thing you'd trip over without it. The report's display formats follow the current culture, so on a Spanish Windows machine the first run printed 1.169,92 instead of 1,169.92. Pinning en-US once at startup makes the amounts format the same on every server.

Step 2: the report layout, with grouping and totals

The layout is defined in code with a fluent builder, on an A4 page, in millimetres. A report is a stack of bands, and each band has a role: the title prints once, the page header on every page, a group header and group footer around each customer, one detail row per order, the summary at the end and the page footer at the bottom of each page.

Add this class at the bottom of Program.cs. It continues in step 3.

// Step 2: the report, defined in code with the fluent builder (A4, millimetres) public static class SalesReport { const string Money = "#,##0.00"; // no currency symbol, so the XLSX export keeps the amounts numeric const string Amount = "Orders.Quantity * Orders.UnitPrice"; public static TsgcReportBuilder Build(IEnumerable<Order> orders, DateTime month) { var report = new TsgcReportBuilder(); report.Title("Sales report").Author("Sales API").Units(TsgcReportUnits.ruMillimeters) // Step 3: the report parameter, printed by the title band as [Month] .Variable("Month", month.ToString("MMMM yyyy", CultureInfo.InvariantCulture)) .Style("Base").Font("Arial", 9).EndStyle() .Style("Title").Font("Arial", 18).Bold().EndStyle() .Style("Group").Font("Arial", 10).Bold().BackColor(0xEEEEEE).EndStyle() .Page().Paper(TsgcReportPaperSize.rpsA4).Margins(15) .ReportTitle(16).Style("Title").Text("Sales report for [Month]", 0, 0, 180, 10).EndBand() .PageHeader(8).Style("Base").Bold() .Text("Date", 0, 1, 25, 5) .Text("Product", 28, 1, 62, 5) .Text("Qty", 92, 1, 18, 5).AlignRight() .Text("Unit price (USD)", 112, 1, 30, 5).AlignRight() .Text("Amount (USD)", 144, 1, 36, 5).AlignRight() .Line(0, 7, 180, 7).EndBand() .GroupHeader("Orders.Customer", 8).Style("Group") .Text("[Orders.Customer]", 0, 1, 180, 6).EndBand() .MasterData("Orders", 6).Style("Base") .Text("[Orders.Date]", 0, 0, 25, 5).Format("yyyy-mm-dd") .Text("[Orders.Product]", 28, 0, 62, 5) .Text("[Orders.Quantity]", 92, 0, 18, 5).AlignRight() .Text("[Orders.UnitPrice]", 112, 0, 30, 5).AlignRight().Format(Money) .Text($"[{Amount}]", 144, 0, 36, 5).AlignRight().Format(Money).EndBand() .GroupFooter(9).Style("Base") .Text("Subtotal [Orders.Customer] ([Count()] orders)", 28, 1, 114, 5).Bold() .Text($"[Sum({Amount})]", 144, 1, 36, 5).AlignRight().Bold().Format(Money).EndBand() .Summary(14).Style("Base").Line(0, 2, 180, 2) .Text("Grand total ([Count()] orders)", 28, 4, 114, 6).Bold() .Text($"[Sum({Amount})]", 144, 4, 36, 6).AlignRight().Bold().Format(Money).EndBand() .PageFooter(8).Style("Base") .Text("Page [Page] of [TotalPages]", 0, 2, 180, 5).AlignRight().EndBand() .EndPage() .Data(CreateSource(orders, month)); return report; } // the month filter runs on the data (as a WHERE clause would); groups need the rows sorted by customer public static TsgcReportEnumerableSource CreateSource(IEnumerable<Order> orders, DateTime month) => new() { SourceName = "Orders", Items = orders.Where(o => o.Date.Year == month.Year && o.Date.Month == month.Month) .OrderBy(o => o.Customer).ThenBy(o => o.Date).ToList() }; 

A few things are worth knowing here.

Text(text, left, top, width, height) places an item inside its band, in millimetres. Anything in square brackets is an expression. [Orders.Customer] reads a field, [Orders.Quantity * Orders.UnitPrice] computes the line amount, and [Sum(...)] and [Count()] add up the rows. In the group footer they cover the current customer, in the summary they cover the whole month. [Page] and [TotalPages] number the pages.

The data source is a TsgcReportEnumerableSource named Orders. Its fields are the public properties of the items, so a C# record with DateTime, int and decimal properties works as is.

GroupHeader("Orders.Customer", 8) starts a new group each time the customer value changes. That's why CreateSource sorts by customer, then by date, before handing the list over. Sort your rows by the group expression.

Formats come in two styles. Numbers use #,##0.00, and dates use yyyy-mm-dd, with a lower case mm for the month.

Notice that the Money format has no currency symbol, and the currency sits in the column headers as "Unit price (USD)" and "Amount (USD)". That's deliberate. With $#,##0.00 the PDF looked fine, but the XLSX export stored $18.50 as text, because the spreadsheet export only detects plain numbers. Keeping the symbol out of the format keeps the spreadsheet amounts numeric.

Step 3: the month as a query string parameter

A sales report is always for a period. Here the month arrives as ?month=2026-03, filters the data, and appears in the title.

Two lines in step 2 already do most of it. .Variable("Month", ...) gives the report a variable, and the title band prints it as [Month], so the heading reads "Sales report for March 2026". The filter itself runs in CreateSource, in LINQ, the way a WHERE clause would in your database query. Report variables are stored as strings and referenced by their bare name, so passing a formatted label and filtering the data yourself is the straightforward route.

Close the class with the month parser:

 public static bool TryParseMonth(string? value, out DateTime month) => DateTime.TryParseExact(string.IsNullOrEmpty(value) ? "2026-03" : value, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out month); } 

Without a month, the endpoints fall back to March 2026. A value that isn't yyyy-MM, such as ?month=March, returns 400 with the body "month must look like 2026-03".

Step 4: the PDF, XLSX and CSV endpoints

Each endpoint builds its own report, exports it to a memory stream and returns the bytes with the right content type and a file name. TsgcReportBuilder is IDisposable, so one builder per request inside using keeps requests independent of each other.

Add the endpoints after var orders = SampleData.Orders;. The archive lines are covered right after.

// Step 4: the endpoints. Every request builds its own report, so requests never share state. app.MapGet("/reports/sales.pdf", (string? month, bool? archive) => { if (!SalesReport.TryParseMonth(month, out var period)) return Results.BadRequest("month must look like 2026-03"); using var report = SalesReport.Build(orders, period); if (archive == true) report.PDFExport.PDFA = TsgcReportPDFAMode.rpaA3b; // PDF/A-3b on top of the default PDF/UA-1 var stream = new MemoryStream(); report.SaveToPDFStream(stream); return Results.File(stream.ToArray(), "application/pdf", $"sales-{period:yyyy-MM}.pdf"); }); app.MapGet("/reports/sales.xlsx", (string? month) => { if (!SalesReport.TryParseMonth(month, out var period)) return Results.BadRequest("month must look like 2026-03"); using var report = SalesReport.Build(orders, period); var stream = new MemoryStream(); report.SaveToXLSXStream(stream); return Results.File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"sales-{period:yyyy-MM}.xlsx"); }); app.MapGet("/reports/sales.csv", (string? month) => { if (!SalesReport.TryParseMonth(month, out var period)) return Results.BadRequest("month must look like 2026-03"); using var report = SalesReport.Build(orders, period); var stream = new MemoryStream(); report.SaveToCSVStream(stream); return Results.File(stream.ToArray(), "text/csv", $"sales-{period:yyyy-MM}.csv"); }); 

All three formats come from the same layout, so they always show the same groups and totals. Results.File adds a Content-Disposition: attachment header with a name like sales-2026-03.pdf.

Here's what each format contains:

  • PDF: one A4 page for the sample, with the fonts embedded as subsets. PDF/UA-1 is on by default, so the file carries the PDF/UA-1 marker, a structure tree and the en-US language without any extra option. Set PDFExport.PDFUA = false if you ever need it off.
  • XLSX: one sheet named Report, 23 rows by 5 columns that follow the item positions. You get the title, the column headers, a row per customer group, the detail rows, the subtotals and the grand total. Quantities, prices, amounts and totals are numeric cells.
  • CSV: the same rows as the spreadsheet, as text, starting with a UTF-8 BOM.

Two limits to keep in mind. The XLSX doesn't carry the number format, so a spreadsheet shows 222 rather than 222.00, and dates are text cells. Both grid exports include the title, header and group rows, not only the data rows, while the page footer is left out.

PDF/A-3b in one line

When a report has to be stored as PDF/A, you add one line, and it's already in the PDF endpoint:

 if (archive == true) report.PDFExport.PDFA = TsgcReportPDFAMode.rpaA3b; // PDF/A-3b on top of the default PDF/UA-1 

Call the endpoint with ?archive=true and the file is marked as PDF/A-3b in its metadata (pdfaid:part=3, pdfaid:conformance=B), while it keeps the PDF/UA-1 marker. Only the markers were checked. The file wasn't run through a conformance validator, so validate it yourself if your archive requires that. The same property also takes rpaA3a and rpaA4.

Step 5: save and load the layout as JSON

A layout in code is fine for one report. Stored as data, it can be versioned and shipped with the app, and the app renders from the stored definition at runtime.

Add these two endpoints after the CSV one, followed by the line that starts the app:

// Step 5: the layout as JSON, and a PDF rendered from a layout loaded back from that JSON app.MapGet("/reports/sales.layout.json", () => { using var report = SalesReport.Build(Array.Empty<Order>(), new DateTime(2026, 3, 1)); report.Flush(); // commits the last band before the layout is serialized return Results.Text(sgcReports_Definition.sgcReportSaveToJSON(report.Report, true), "application/json"); }); app.MapGet("/reports/sales-from-json.pdf", (string? month) => { if (!SalesReport.TryParseMonth(month, out var period)) return Results.BadRequest("month must look like 2026-03"); var layout = new TsgcReport(); sgcReports_Definition.sgcReportLoadFromJSONFile(layout, Path.Combine(AppContext.BaseDirectory, "sales.report.json")); var engine = new TsgcReportEngine { Report = layout }; engine.AddDataSource(SalesReport.CreateSource(orders, period)); engine.Variables.SetValue("Month", periodSource:https://dev.to/esegece_com/build-a-sales-report-api-in-aspnet-core-with-pdf-xlsx-and-csv-5be3

Related posts