Back to blog
excel·8 min read

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

Khatab Wedaa

Software Engineer · Nestbolt

How to Export Data to Excel in NestJS (XLSX, CSV, and Streaming Downloads)

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/export returning a real .xlsx file with the right Content-Type and Content-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:

LibraryFormatStrengthWatch out
exceljsXLSX, CSVFull styling, multi-sheet, streaming writerHeavy install size
xlsx (SheetJS)XLSX, CSV, ODSMany formats, fastThe free build is read-focused; styling is paid
fast-csvCSV onlyTiny, very fast streamingNo 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 exceljs

2. 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:

  • StreamableFile instead of res.send(). It plays nicely with NestJS interceptors and the framework knows how to clean up the response.
  • Explicit Content-Type and Content-Disposition. Without these the browser shows a blob preview instead of triggering a download.
  • Column metadata up front. Setting widths and headers via sheet.columns produces 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, exceljs buffers rows in memory.
  • Page through the database. findAllStream should be a LIMIT/OFFSET cursor, not find(). 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

PitfallWhat goes wrongFix
Returning res.send(buffer) directlyMemory blows up on large exportsUse the streaming WorkbookWriter and commit() rows
Forgetting Content-DispositionBrowser opens the file inline as garbageAlways set attachment; filename="..."
Storing dates as stringsExcel sorts "10/1" before "9/1" lexicallyPass a real Date object or ISO 8601 string with a date format
Scientific notation on big IDs1234567890123456 becomes 1.23457E+15Format the column as text or prefix with a single quote
Formula injectionA user-supplied value `=cmd...` runs commands when the file is opened
Loading 1M rows via find()OOM before streaming ever startsPage with take/skip (or keyset) and yield batches
Wrong Content-TypemacOS Quick Look refuses to previewUse the full MIME application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Same filename on every downloadBrowsers overwrite the previous fileAppend 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() decorators
  • ExcelService.downloadFromEntityAsStream(EntityClass, rows, filename) returning a StreamableFile with the correct headers
  • Automatic CSV vs XLSX detection from the filename extension, or an explicit ExcelType enum
  • ExcelService.storeFromEntity() for writing exports to local disk, S3, GCS, or Azure (configurable storage drivers)
  • ExcelService.rawFromEntity() if you need the raw Buffer to 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.

Khatab Wedaa

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.