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.