VSATutorialSeptember 2026 · 7 min read

VSA Todo App — Part 17: Excel Export & Reporting with ClosedXML in VSA

TL;DR

Part 17 adds Excel export and reporting to VSA using ClosedXML. We build the flat TodoExportItem DTO with resolved audit emails, a server-side export endpoint that respects the search filter, and client-side triggers from both the Blazor (JS interop) and MVC (SheetJS/endpoint) frontends. Your users get a production-quality .xlsx file with styled headers.

Part 17 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 16|Next: Part 18 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

Every business application eventually needs to export data. Users want to open their data in Excel, filter it, pivot it, and share it with colleagues. The MVC Project Manager implements a server-side Excel export endpoint, while the Blazor CRM generates Excel entirely client-side with ClosedXML. Both approaches serve the same VSA backend — this part shows how to build both and when to choose each.

The key architectural decision is the flat export DTO. The list query returns TodoListItem with nested structures and user IDs. The export needs a flat, human-readable row: auto-number, name, priority, category, progress, status, owner email, created by email, and updated by email. A separate TodoExportItem DTO solves this without polluting the list DTO.

The Flat Export DTO with Resolved Audit Emails

The TodoExportItem is a flat DTO designed specifically for spreadsheet rows. It resolves user IDs to emails so the exported file is self-documenting:

public class TodoExportItem
{
    public string? AutoNumber { get; set; }
    public string? Name { get; set; }
    public string? Priority { get; set; }
    public string? Category { get; set; }
    public int Progress { get; set; }
    public string? Status { get; set; }
    public string? OwnerEmail { get; set; }
    public string? CreatedByEmail { get; set; }
    public string? UpdatedByEmail { get; set; }
    public DateTimeOffset? CreatedAt { get; set; }
}

Notice how the enums are projected to their display names (Priority as "High" not "1") and user IDs are replaced with emails. The export handler builds this DTO by querying the todos, resolving owners and audit users in a separate lookup, then composing the flat rows. The list handler stays untouched — reporting is a separate concern with its own DTO.

Server-Side Export Endpoint (MVC Project Manager)

The MVC Project Manager exposes GET /api/todo/export. The handler applies the same search filter as the list query but ignores pagination — users export everything they'd see after searching:

group.MapGet("/export", async (string? search, IMediator mediator) =>
{
    var exportData = await mediator.Send(new GetTodoExportQuery(search));
    using var workbook = new XLWorkbook();
    var worksheet = workbook.Worksheets.Add("Todos");

    worksheet.Cell(1, 1).Value = "No";
    worksheet.Cell(1, 2).Value = "Name";
    worksheet.Cell(1, 3).Value = "Priority";
    worksheet.Cell(1, 4).Value = "Category";
    worksheet.Cell(1, 5).Value = "Progress";
    worksheet.Cell(1, 6).Value = "Status";
    worksheet.Cell(1, 7).Value = "Owner";
    worksheet.Cell(1, 8).Value = "Created By";
    worksheet.Cell(1, 9).Value = "Updated By";
    worksheet.Cell(1, 10).Value = "Created At";

    var headerRange = worksheet.Range(1, 1, 1, 10);
    headerRange.Style.Font.Bold = true;
    headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1");
    headerRange.Style.Font.FontColor = XLColor.White;

    int row = 2;
    foreach (var item in exportData)
    {
        worksheet.Cell(row, 1).Value = item.AutoNumber;
        worksheet.Cell(row, 2).Value = item.Name;
        worksheet.Cell(row, 3).Value = item.Priority;
        worksheet.Cell(row, 4).Value = item.Category;
        worksheet.Cell(row, 5).Value = item.Progress;
        worksheet.Cell(row, 6).Value = item.Status;
        worksheet.Cell(row, 7).Value = item.OwnerEmail;
        worksheet.Cell(row, 8).Value = item.CreatedByEmail;
        worksheet.Cell(row, 9).Value = item.UpdatedByEmail;
        worksheet.Cell(row, 10).Value = item.CreatedAt?.ToString("yyyy-MM-dd HH:mm");
        row++;
    }
    worksheet.Columns().AdjustToContents();

    using var stream = new MemoryStream();
    workbook.SaveAs(stream);
    var content = stream.ToArray();
    return Results.File(content,
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        $"Todos_{DateTime.Now:yyyyMMdd_HHmm}.xlsx");
});

The styled header (bold white text on a deep blue fill) and auto-fitted columns make the export look professional immediately. The filename includes a timestamp so multiple exports don't overwrite each other in the user's downloads folder.

Client-Side Export (Blazor CRM)

The Blazor CRM generates Excel client-side using ClosedXML in the browser, triggered by a button and delivered via JS interop. This is ideal for Blazor because the data is already loaded in the component's memory — no extra server round-trip:

using (var workbook = new XLWorkbook())
{
    var worksheet = workbook.Worksheets.Add("Todos");
    worksheet.Cell(1, 1).Value = "Auto Number";
    worksheet.Cell(1, 2).Value = "Todo Name";
    worksheet.Cell(1, 3).Value = "Start Time";
    worksheet.Cell(1, 4).Value = "End Time";
    worksheet.Cell(1, 5).Value = "Status";
    worksheet.Cell(1, 6).Value = "Description";

    var headerRange = worksheet.Range(1, 1, 1, 6);
    headerRange.Style.Font.Bold = true;
    headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1");
    headerRange.Style.Font.FontColor = XLColor.White;

    foreach (var item in GetFilteredData())
    {
        currentRow++;
        worksheet.Cell(currentRow, 1).Value = item.AutoNumber;
        worksheet.Cell(currentRow, 2).Value = item.Name;
        worksheet.Cell(currentRow, 3).Value = item.StartTime?.ToString("yyyy-MM-dd HH:mm");
        worksheet.Cell(currentRow, 4).Value = item.EndTime?.ToString("yyyy-MM-dd HH:mm");
        worksheet.Cell(currentRow, 5).Value = item.IsCompleted ? "Completed" : "Pending";
        worksheet.Cell(currentRow, 6).Value = item.Description;
    }
    worksheet.Columns().AdjustToContents();

    using (var stream = new MemoryStream())
    {
        workbook.SaveAs(stream);
        var content = Convert.ToBase64String(stream.ToArray());
        await JSRuntime.InvokeVoidAsync("downloadFile",
            "Todo_List.xlsx",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            content);
        Snackbar.Add("Excel exported successfully", Severity.Success);
    }
}

The export button shows a spinner while processing, exports the filtered dataset (respecting the current search), and confirms with a snackbar. The small downloadFile JS helper decodes the base64 and triggers the browser download.

Server-Side vs Client-Side: Choosing

Choose server-side export when the dataset is large (thousands of rows), the data must respect server-side filters and permissions, or you want consistent Excel generation regardless of client. The MVC Project Manager's endpoint is the right pattern for data-heavy reporting.

Choose client-side export when the dataset is small to medium (under ~1000 rows), the data is already loaded client-side, and you want instant response without a server round-trip. The Blazor CRM's approach is perfect for admin grids where users work with filtered subsets anyway.

Both approaches share the same VSA principle: export logic is a feature concern that lives in the Todo slice. The export DTO, the endpoint (or component method), and the ClosedXML generation are all co-located — a developer can understand and modify the entire export feature by opening one folder.

Key Takeaways

  • Flat export DTOs (TodoExportItem) keep spreadsheet rows human-readable — enums as names, user IDs as emails
  • Server-side export endpoints respect search filters but ignore pagination — export everything the user sees
  • ClosedXML headers with bold white text on a blue fill make exports look professional immediately
  • Client-side export via JS interop is ideal when data is already loaded in the Blazor component
  • Export logic is a feature concern — the DTO, endpoint, and ClosedXML generation live in the Todo slice

Frequently Asked Questions

Q: How to export data from VSA to Excel?

Use ClosedXML to generate the workbook. For server-side export, create an endpoint that applies the search filter, projects to a flat export DTO, writes the workbook, and returns it via Results.File. For client-side (Blazor), generate the workbook in the component and trigger the download via JS interop.

Q: Server-side vs client-side Excel generation — which should I use?

Server-side for large datasets, server-side filters/permissions, or consistent generation across clients. Client-side (Blazor + ClosedXML + JS interop) when data is already loaded and you want instant downloads. For medium datasets both work; choose based on where the data already lives.

Q: How to include audit data in VSA exports?

Create a flat export DTO with email fields (CreatedByEmail, UpdatedByEmail). After querying todos, resolve user IDs to emails in a separate lookup and compose the flat rows. The main list handler stays untouched — reporting is a separate concern with its own DTO.

Q: What DTO structure for Excel exports in VSA?

Use a flat DTO where each property maps to one spreadsheet column. Enums project to display names, user IDs to emails, and dates to formatted strings. Flat DTOs are trivial to write row-by-row with ClosedXML and avoid nested data that spreads across multiple rows.

Part 17 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 16|Next: Part 18 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use.

Star on GitHub