How to Export Data to Excel in NestJS (XLSX, CSV, and Streaming Downloads)
A complete walkthrough of exporting data to Excel from a NestJS API — generate XLSX with ExcelJS, stream large exports without blowing up memory, support CSV, and avoid the formatting traps that bite teams in production.
Khatab Wedaa
Software Engineer · Nestbolt
Every dashboard eventually grows an "Export to Excel" button. Sometimes it's an executive who wants a static report. Sometimes it's an accountant who lives in spreadsheets. Sometimes it's compliance asking for a quarterly user list. The first time you build it, you write a route that loads everything into memory, builds an array of arrays, calls res.send(buffer), and call it done. Then the dataset grows from 5,000 rows to 500,000 and your Node process OOMs in production.
This guide walks through exporting data to Excel from a NestJS API the right way the first time — choosing a library, generating XLSX with proper formatting, streaming the response so memory stays flat, supporting CSV through the same code path, and the traps (date timezones, cell formula injection, large-number precision) that bite teams the day after launch.
What you'll build
By the end of this tutorial, your NestJS app will support:
GET /users/exportreturning a real.xlsxfile with the rightContent-TypeandContent-Disposition- A reusable export pattern driven by entity decorators — declare columns once, reuse across exports
- Streaming so a 1M-row export doesn't allocate a 200MB buffer
- CSV from the same export definition, just by changing the file extension
- An "email me the export" path that writes the workbook to S3 and returns a signed URL
Prerequisites
- Node.js 18 or later
- A NestJS 10+ project
- A dataset to export — anything you can pull from your database via TypeORM, Prisma, or a raw SQL query
1. Pick a library
There are three libraries worth knowing about:
| Library | Format | Strength | Watch out |
|---|---|---|---|
exceljs | XLSX, CSV | Full styling, multi-sheet, streaming writer | Heavy install size |
xlsx (SheetJS) | XLSX, CSV, ODS | Many formats, fast | The free build is read-focused; styling is paid |
fast-csv | CSV only | Tiny, very fast streaming | No XLSX |
For most NestJS apps, exceljs is the right default — it produces real .xlsx files (not CSVs renamed), supports multi-sheet workbooks, and has a streaming API for the large-export case. Install it:
npm install exceljs2. The minimal export endpoint
Start small. A working route that returns five columns, no streaming, no fancy formatting:
// src/users/users.controller.ts
import { Controller, Get, StreamableFile, Header } from "@nestjs/common";
import { Workbook } from "exceljs";
import { UsersService } from "./users.service";
@Controller("users")
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get("export")
@Header(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
@Header("Content-Disposition", 'attachment; filename="users.xlsx"')
async export() {
const users = await this.users.findAll();
const wb = new Workbook();
const sheet = wb.addWorksheet("Users");
sheet.columns = [
{ header: "ID", key: "id", width: 36 },
{ header: "Name", key: "name", width: 24 },
{ header: "Email", key: "email", width: 32 },
{ header: "Role", key: "role", width: 12 },
{ header: "Created", key: "createdAt", width: 20 },
];
for (const user of users) sheet.addRow(user);
const buffer = (await wb.xlsx.writeBuffer()) as Buffer;
return new StreamableFile(buffer);
}
}A few things this gets right:
StreamableFileinstead ofres.send(). It plays nicely with NestJS interceptors and the framework knows how to clean up the response.- Explicit
Content-TypeandContent-Disposition. Without these the browser shows a blob preview instead of triggering a download. - Column metadata up front. Setting widths and headers via
sheet.columnsproduces a properly formatted file, not a wall of unsized columns.
This works for a few thousand rows. For more, you stream.
3. Streaming for large exports
The problem with await wb.xlsx.writeBuffer() is that it builds the entire workbook in memory before sending the first byte. For a million-row export that's hundreds of megabytes, plus all the row objects you loaded from the database. Streaming flips both: rows are flushed to the response as they're generated, and you never hold more than a single batch in memory.
exceljs has a streaming writer:
import { stream } from "exceljs";
import { Response } from "express";
@Get("export")
async export(@Res({ passthrough: false }) res: Response) {
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader("Content-Disposition", 'attachment; filename="users.xlsx"');
const wb = new stream.xlsx.WorkbookWriter({ stream: res });
const sheet = wb.addWorksheet("Users");
sheet.columns = [
{ header: "ID", key: "id", width: 36 },
{ header: "Name", key: "name", width: 24 },
{ header: "Email", key: "email", width: 32 },
];
for await (const batch of this.users.findAllStream({ batchSize: 1000 })) {
for (const user of batch) sheet.addRow(user).commit();
}
await sheet.commit();
await wb.commit();
}A few important details:
- Use
passthrough: false. Streaming requires direct response control; NestJS's serializer would otherwise wrap the stream and corrupt it. - Call
row.commit()on every row. Without this,exceljsbuffers rows in memory. - Page through the database.
findAllStreamshould be aLIMIT/OFFSETcursor, notfind(). Loading all rows defeats the purpose.
For TypeORM, the cursor looks like this:
async *findAllStream({ batchSize = 1000 } = {}) {
let offset = 0;
for (;;) {
const rows = await this.repo.find({
take: batchSize,
skip: offset,
order: { id: "ASC" },
});
if (rows.length === 0) return;
yield rows;
offset += rows.length;
}
}For very large tables, prefer keyset pagination (WHERE id > :lastId) over OFFSET — the latter gets quadratically slower as the offset grows.
4. Decorator-driven exports
Hand-writing sheet.columns for every entity gets repetitive. The pattern that scales is to declare columns once, on the entity:
// src/excel/decorators.ts
const COLUMNS = Symbol("export-columns");
const SHEET = Symbol("export-sheet");
export interface ColumnOptions {
header: string;
order?: number;
width?: number;
format?: (value: unknown) => unknown;
}
export const ExportColumn =
(options: ColumnOptions): PropertyDecorator =>
(target, propertyKey) => {
const list: { key: string; options: ColumnOptions }[] =
Reflect.getMetadata(COLUMNS, target.constructor) ?? [];
list.push({ key: propertyKey as string, options });
Reflect.defineMetadata(COLUMNS, list, target.constructor);
};
export const Exportable =
(options: { title?: string }): ClassDecorator =>
(target) => {
Reflect.defineMetadata(SHEET, options, target);
};Then on the entity:
@Exportable({ title: "Users" })
@Entity("users")
export class User {
@ExportColumn({ header: "ID", order: 1, width: 36 })
@PrimaryGeneratedColumn("uuid")
id!: string;
@ExportColumn({ header: "Name", order: 2, width: 24 })
@Column()
name!: string;
@ExportColumn({ header: "Email", order: 3, width: 32 })
@Column({ unique: true })
email!: string;
@ExportColumn({
header: "Created",
order: 4,
width: 20,
format: (v) => (v as Date).toISOString(),
})
@CreateDateColumn()
createdAt!: Date;
@Column() // not exportable — no decorator
passwordHash!: string;
}A small ExcelService reads the metadata and emits the file:
@Injectable()
export class ExcelService {
async fromEntity<T>(EntityClass: new () => T, rows: T[], filename: string) {
const sheetMeta: { title?: string } =
Reflect.getMetadata(SHEET, EntityClass) ?? {};
const cols: { key: string; options: ColumnOptions }[] =
Reflect.getMetadata(COLUMNS, EntityClass) ?? [];
cols.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0));
const wb = new Workbook();
const sheet = wb.addWorksheet(sheetMeta.title ?? "Sheet1");
sheet.columns = cols.map((c) => ({
header: c.options.header,
key: c.key,
width: c.options.width,
}));
for (const row of rows) {
const out: Record<string, unknown> = {};
for (const c of cols) {
const v = (row as Record<string, unknown>)[c.key];
out[c.key] = c.options.format ? c.options.format(v) : v;
}
sheet.addRow(out);
}
const buffer = (await wb.xlsx.writeBuffer()) as Buffer;
return new StreamableFile(buffer, {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
disposition: `attachment; filename="${filename}"`,
});
}
}Now any export is two lines:
@Get("export")
async export() {
return this.excel.fromEntity(User, await this.users.findAll(), "users.xlsx");
}The decorator-driven approach forces a useful discipline: passwords, tokens, and other sensitive fields don't accidentally make it into the export — they're absent unless someone explicitly adds @ExportColumn to them.
5. CSV from the same definition
CSV is just a different writer over the same column list. exceljs already has it:
async csvFromEntity<T>(EntityClass: new () => T, rows: T[], filename: string) {
const wb = new Workbook();
// ... same column setup as fromEntity ...
const buffer = (await wb.csv.writeBuffer()) as Buffer;
return new StreamableFile(buffer, {
type: "text/csv; charset=utf-8",
disposition: `attachment; filename="${filename}"`,
});
}The two formats share 90% of the code. In a real implementation you detect the file type from the requested extension and pick the writer.
6. Common pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
Returning res.send(buffer) directly | Memory blows up on large exports | Use the streaming WorkbookWriter and commit() rows |
Forgetting Content-Disposition | Browser opens the file inline as garbage | Always set attachment; filename="..." |
| Storing dates as strings | Excel sorts "10/1" before "9/1" lexically | Pass a real Date object or ISO 8601 string with a date format |
| Scientific notation on big IDs | 1234567890123456 becomes 1.23457E+15 | Format the column as text or prefix with a single quote |
| Formula injection | A user-supplied value `=cmd | ...` runs commands when the file is opened |
Loading 1M rows via find() | OOM before streaming ever starts | Page with take/skip (or keyset) and yield batches |
Wrong Content-Type | macOS Quick Look refuses to preview | Use the full MIME application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| Same filename on every download | Browsers overwrite the previous file | Append a timestamp: users-2026-04-29.xlsx |
The formula injection one is the sneakiest. A user signs up with the name =2+2, your admin opens the export in Excel, and the cell evaluates as 4. Replace with =cmd|'/c calc'!A1 and Excel runs commands. Always sanitize.
Going further: @nestbolt/excel
The decorator-driven pattern in step 4 is what we extracted into @nestbolt/excel. Building it from scratch each project means re-deriving the same metadata registry, the same column ordering, the same MIME-type wiring, and the same set of edge cases.
The package ships:
@Exportable({ title }),@ExportColumn({ header, order, width, format }), and@ExportIgnore()decoratorsExcelService.downloadFromEntityAsStream(EntityClass, rows, filename)returning aStreamableFilewith the correct headers- Automatic CSV vs XLSX detection from the filename extension, or an explicit
ExcelTypeenum ExcelService.storeFromEntity()for writing exports to local disk, S3, GCS, or Azure (configurable storage drivers)ExcelService.rawFromEntity()if you need the rawBufferto attach to an email- Sheet-level options like auto-filter and frozen rows when you need them
Setup is one line — ExcelModule.forRoot() in your AppModule. The full API is in the excel quick-start.
Wrapping up
Exporting to Excel is one of those features that looks trivial on the demo and gets gnarly the moment real users hit it — large datasets, weird locales, accountants who copy-paste columns into other workbooks, dates that span timezones, IDs that get rounded into oblivion. The good news is the patterns are all known: stream the writer, declare columns with metadata, sanitize user input, and let the browser handle the download with the right headers. Once those four are in place, adding "export to Excel" to a new endpoint is a five-line change.
If this saved you from rewriting the same export pipeline for the third project in a row, star the repo. If you'd like a follow-up on multi-sheet exports or imports (validating an uploaded XLSX), open an issue on GitHub.
Written by Khatab Wedaa
Software Engineer · Nestbolt
Building open-source NestJS packages — authentication, permissions, audit logs, media uploads, and the patterns every backend ends up rebuilding.