File Upload & Streaming

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.x), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, as of 2026-10-02. Files travel in both directions in a web API: clients upload covers, avatars and CSV imports as multipart/form-data, and the server sends exports, generated documents and images back as streams. Nest covers both with a small set of building blocks: upload interceptors (backed by Multer on Express, and since 12.1 by @fastify/multipart on Fastify), the @UploadedFile() and @UploadedFiles() parameter decorators, ParseFilePipe for validation, StreamableFile for downloads, and the rawBody option for the request body bytes before parsing.

All examples extend the running Bookshelf scenario: books have a cover image, galleries and an optional manuscript upload, authors have an avatar and a banner, and a payment webhook needs its raw body. The examples use the ESM layout generated by the Nest 12 CLI and TypeScript 6, and the responses shown are the same on the Express and the Fastify adapter. The related Interceptors, Pipes & Validation, Request Lifecycle, Configuration and Serialization pages explain the machinery this page builds on.

Where uploads sit in the request lifecycle

A file upload is not a special kind of route. It is an ordinary route with an interceptor that does the multipart parsing and a pipe that validates the result:

  • The upload interceptor (FileInterceptor() and friends) runs in the interceptor phase, after middleware and guards. It parses the multipart/form-data body, stores each file (in memory or via a storage engine), and puts the result on the request: req.file or req.files for files, req.body for the text fields.

  • The @UploadedFile() and @UploadedFiles() decorators then read req.file / req.files, and any pipe passed to them (such as ParseFilePipe) runs as a parameter pipe, after the interceptor.

  • Because guards run before interceptors, an unauthorized client is rejected before a single byte of the upload is parsed. Authenticate and authorize with a guard, never inside the handler.

  • Exceptions raised by the interceptor (a file that is too large, an unexpected field) and by the pipe flow into the normal exception filters, so the error shape is the usual { message, error, statusCode } body.

flowchart LR C[Client multipart request] --> G[Guards] G -->|denied| E1[403 before parsing] G --> I[Upload interceptor parses and stores] I -->|limit or field error| E2[413 or 400] I --> P[ParseFilePipe validators] P -->|invalid| E3[400 or custom status] P --> H[Handler receives the file] H --> R[Response]

Setup: Express and Fastify

On the default Express adapter, uploads are handled by Multer, which the @nestjs/platform-express package bundles. Install only the type definitions:

npm i -D @types/multer

With them installed the file type is Express.Multer.File. Multer processes only multipart/form-data bodies; any other content type is not parsed by it.

On the Fastify adapter, starting with NestJS 12.1 the same interceptors exist in @nestjs/platform-fastify/multipart, backed by @fastify/multipart instead of Multer. Install the plugin once; there is no need to register it, because FastifyAdapter registers it as soon as an upload interceptor is used (and not at all in applications that have none):

npm i @fastify/multipart

If the package is missing, the application fails at start-up with an error naming it. Everything exported from @nestjs/platform-fastify/multipart is also re-exported from the @nestjs/platform-fastify root. The interceptors only work with FastifyAdapter; with ExpressAdapter the application fails to start, so keep importing from @nestjs/platform-express there. Files are typed as UploadedMultipartFile, which has the same shape as Express.Multer.File, so @types/multer is not needed.

Express and Fastify side by side

Express (@nestjs/platform-express) Fastify (@nestjs/platform-fastify/multipart, 12.1+)

Engine

Multer

@fastify/multipart

Interceptors

FileInterceptor, FilesInterceptor, FileFieldsInterceptor, AnyFilesInterceptor, NoFilesInterceptor

Same names, arguments and options, plus FileStreamInterceptor (Fastify only)

Options type

MulterOptions

MultipartOptions

Module and factory

MulterModule, MulterOptionsFactory, createMulterOptions()

MultipartModule, MultipartOptionsFactory, createMultipartOptions()

File type

Express.Multer.File (needs @types/multer)

UploadedMultipartFile

Storage helpers

memoryStorage(), diskStorage() from the multer package

memoryStorage(), diskStorage() from @nestjs/platform-fastify/multipart; Multer storage engines can also be passed unchanged

Default size limit

None

fileSize is the Fastify bodyLimit (1 MiB); parts is 1000. Larger files get 413 File too large

Streaming upload

Not provided; read the request stream yourself (see below)

FileStreamInterceptor()

Unsafe field names such as constructor

Accepted

Rejected with 400 Invalid field name

The five interceptors

All five are factories that return an interceptor class. They are bound to a route with @UseInterceptors() and they differ in how many files, and under which field names, they accept. The decorators that read the result, @UploadedFile() and @UploadedFiles(), come from @nestjs/common.

Interceptor Accepts Read with

FileInterceptor(fieldName, options?)

One file in fieldName

@UploadedFile()

FilesInterceptor(fieldName, maxCount?, options?)

Several files in one field, at most maxCount

@UploadedFiles() (an array)

FileFieldsInterceptor(uploadedFields, options?)

Files in several named fields; each entry is { name, maxCount? }

@UploadedFiles() (an object of arrays keyed by field name)

AnyFilesInterceptor(options?)

Files under any field name

@UploadedFiles() (an array; check fieldname yourself)

NoFilesInterceptor()

No files: only text fields of a multipart/form-data body; a request that contains a file is rejected with BadRequestException

@Body()

The options argument of the first four is the same object Multer’s constructor accepts. The following controller uses all five against the Express adapter. Its first handler is the one a Bookshelf client calls to set a book’s cover, with validation that is explained in the next section.

src/books/covers.controller.ts (Express)
import {
  Body,
  Controller,
  HttpStatus,
  Param,
  ParseFilePipeBuilder,
  Post,
  UploadedFile,
  UploadedFiles,
  UseInterceptors,
} from '@nestjs/common';
import {
  AnyFilesInterceptor,
  FileFieldsInterceptor,
  FileInterceptor,
  FilesInterceptor,
  NoFilesInterceptor,
} from '@nestjs/platform-express';
import { memoryStorage } from 'multer';

@Controller()
export class CoversController {
  // Single file in the field "cover", validated by the builder.
  @Post('books/:id/cover')
  @UseInterceptors(FileInterceptor('cover', { storage: memoryStorage() }))
  uploadCover(
    @Param('id') id: string,
    @Body('caption') caption: string | undefined,
    @UploadedFile(
      new ParseFilePipeBuilder()
        .addFileTypeValidator({ fileType: /^image\/(png|jpeg)$/ })
        .addMaxSizeValidator({ maxSize: 64 * 1024 })
        .build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY }),
    )
    cover: Express.Multer.File,
  ) {
    return {
      bookId: id,
      caption,
      name: cover.originalname,
      mimetype: cover.mimetype,
      size: cover.size,
      storedAs: cover.filename ?? null,
    };
  }

  // Several files under one field name, at most 3.
  @Post('books/:id/gallery')
  @UseInterceptors(FilesInterceptor('photos', 3))
  uploadGallery(@UploadedFiles() photos: Array<Express.Multer.File>) {
    return photos.map((p) => ({ name: p.originalname, size: p.size }));
  }

  // Different field names, each with its own maxCount.
  @Post('authors/:id/images')
  @UseInterceptors(
    FileFieldsInterceptor([
      { name: 'avatar', maxCount: 1 },
      { name: 'banner', maxCount: 1 },
    ]),
  )
  uploadAuthorImages(
    @UploadedFiles()
    files: { avatar?: Express.Multer.File[]; banner?: Express.Multer.File[] },
  ) {
    return {
      avatar: files.avatar?.[0]?.originalname ?? null,
      banner: files.banner?.[0]?.originalname ?? null,
    };
  }

  // Any field name; inspect fieldname yourself.
  @Post('import')
  @UseInterceptors(AnyFilesInterceptor())
  importAnything(@UploadedFiles() files: Array<Express.Multer.File>) {
    return files.map((f) => ({ field: f.fieldname, name: f.originalname }));
  }

  // multipart/form-data with text fields only; any file is rejected.
  @Post('reviews/form')
  @UseInterceptors(NoFilesInterceptor())
  reviewForm(@Body() body: Record<string, string>) {
    return body;
  }
}

Calling the cover route with curl and the response (the PNG is 70 bytes):

curl -X POST http://localhost:3000/books/7/cover \
  -F 'caption=First edition' \
  -F 'cover=@cover.png;type=image/png'
{
  "bookId": "7",
  "caption": "First edition",
  "name": "cover.png",
  "mimetype": "image/png",
  "size": 70,
  "storedAs": null
}

storedAs is null because the cover is held in memory (file.buffer is set and file.filename is not); see Storage. Text fields that accompany the file arrive in @Body() exactly like JSON fields, so @Body('caption') works. The other four routes behave as follows (the responses are the same on both adapters):

Request Status Body

POST /books/7/gallery with two photos

201

[{"name":"a.png","size":70},{"name":"b.png","size":70}]

POST /books/7/gallery with four photos (limit 3)

400

{"message":"Unexpected file field - photos","error":"Bad Request","statusCode":400}

POST /books/7/gallery with the file in field other

400

{"message":"Unexpected file field - other", …​}

POST /authors/3/images with avatar and banner

201

{"avatar":"me.png","banner":"b.png"}

POST /reviews/form with rating and text fields only

201

{"rating":"5","text":"good"}

POST /reviews/form with a file

400

{"message":"Unexpected file field - file", …​}

The error text for a field that is not declared, or that exceeds its maxCount, is the same Unexpected file field message. A client that sends a file under the wrong form field name is the most common cause of a mysterious 400 on upload routes.

The Fastify version of the same controller

On Fastify the controller keeps its shape; only the import path and the file type change. This example also adds the streaming interceptor discussed later.

src/books/covers-fastify.controller.ts
import {
  Body,
  Controller,
  HttpStatus,
  Param,
  ParseFilePipeBuilder,
  Post,
  UploadedFile,
  UploadedFiles,
  UseInterceptors,
} from '@nestjs/common';
import {
  FileFieldsInterceptor,
  FileInterceptor,
  FilesInterceptor,
  memoryStorage,
  NoFilesInterceptor,
  type UploadedMultipartFile,
} from '@nestjs/platform-fastify/multipart';

@Controller()
export class CoversFastifyController {
  @Post('books/:id/cover')
  @UseInterceptors(FileInterceptor('cover', { storage: memoryStorage() }))
  uploadCover(
    @Param('id') id: string,
    @Body('caption') caption: string | undefined,
    @UploadedFile(
      new ParseFilePipeBuilder()
        .addFileTypeValidator({ fileType: /^image\/(png|jpeg)$/ })
        .addMaxSizeValidator({ maxSize: 64 * 1024 })
        .build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY }),
    )
    cover: UploadedMultipartFile,
  ) {
    return { bookId: id, caption, name: cover.originalname, size: cover.size };
  }

  @Post('books/:id/gallery')
  @UseInterceptors(FilesInterceptor('photos', 3))
  uploadGallery(@UploadedFiles() photos: UploadedMultipartFile[]) {
    return photos.map((p) => ({ name: p.originalname, size: p.size }));
  }

  @Post('authors/:id/images')
  @UseInterceptors(
    FileFieldsInterceptor([
      { name: 'avatar', maxCount: 1 },
      { name: 'banner', maxCount: 1 },
    ]),
  )
  uploadAuthorImages(
    @UploadedFiles()
    files: { avatar?: UploadedMultipartFile[]; banner?: UploadedMultipartFile[] },
  ) {
    return { avatar: files.avatar?.[0]?.originalname ?? null };
  }

  @Post('reviews/form')
  @UseInterceptors(NoFilesInterceptor())
  reviewForm(@Body() body: Record<string, string>) {
    return body;
  }
}

Validating uploads with ParseFilePipe

A client controls the file name, the declared MIME type and the size, so the server must check them. Nest’s built-in validator is ParseFilePipe, a pipe you pass to @UploadedFile() or @UploadedFiles(). It takes an array of validators and runs each of them; the first one that fails raises the error.

ParseFilePipe option Meaning

validators

The array of validator instances to run.

errorHttpStatusCode

Status used when any validator fails. The default is 400 (Bad Request); the Bookshelf examples use 422 (Unprocessable Entity).

exceptionFactory

A function that receives the error message and returns the exception to throw, for a custom error body.

fileIsRequired

true by default. Set to false to let a request without the file through: the handler then receives undefined.

Validators are instances of FileValidator, an abstract class with isValid(file) (which may be asynchronous) and buildErrorMessage(file). Nest ships two:

  • MaxFileSizeValidator({ maxSize }) accepts a file smaller than maxSize bytes. It also takes message and errorMessage options to customize the error text.

  • FileTypeValidator({ fileType }) accepts a file whose MIME type matches the given string or regular expression. By default it detects the type from the content’s magic number, not from the header the client sent.

You can compose them in the constructor, or with ParseFilePipeBuilder, which instantiates the validators for you:

// constructor form
new ParseFilePipe({
  validators: [
    new MaxFileSizeValidator({ maxSize: 1000 }),
    new FileTypeValidator({ fileType: 'image/jpeg' }),
  ],
});

// builder form: addMaxSizeValidator(), addFileTypeValidator(), addValidator(), then build()
new ParseFilePipeBuilder()
  .addFileTypeValidator({ fileType: 'jpeg' })
  .addMaxSizeValidator({ maxSize: 1000 })
  .build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY });

The builder also has addValidator(validator) for your own validators (it appears in the package’s type declarations). build() accepts the same options as the constructor except validators. The cover route in the controller above gives these answers:

Upload Status Body

a real PNG, 70 bytes

201

the JSON shown earlier

text bytes sent with Content-Type: image/png and the name x.png

422

{"message":"Validation failed (current file type is image/png, expected type is /^image\\/(png|jpeg)$/)","error":"Unprocessable Entity","statusCode":422}

a valid PNG padded to 70,070 bytes

422

{"message":"Validation failed (current file size is 70070, expected size is less than 65536)", …​}

no file at all

422

{"message":"File is required","error":"Unprocessable Entity","statusCode":422}

The wrong-type case shows why magic-number detection matters: the client claimed image/png, but the bytes were plain text, and the validator rejected them. (The message prints the declared type, which can look contradictory; the decision was made from the content.)

Pitfall: FileTypeValidator needs the file buffer

The magic-number check reads the file’s bytes, so it only works when the file is in memory (file.buffer). With disk storage there is no buffer. With the module-wide disk storage described below, the cover route’s validator answers with this message:

{
  "message": "Validation failed (file buffer is not available; file type validation could not be performed; expected type is /^image\\/(png|jpeg)$/)",
  "error": "Unprocessable Entity",
  "statusCode": 422
}

That is why the controller above overrides the storage on that route with memoryStorage(): covers are small, and verifying the real type is worth the memory. For large files stored on disk, use skipMagicNumbersValidation: true and accept that only the client-provided MIME type is compared (documented on the official page), or validate after the fact. The type declarations of the installed 12.1.2 package additionally list fallbackToMimetype (fall back to the MIME type when the magic-number check fails), overrideMimeType (replace the client’s MIME type with the detected one) and errorMessage (a string, or a function of the validation context) on FileTypeValidatorOptions; these are not described on the official page, so check the declarations of the version you install before relying on them.

Custom validators, optional files and exceptionFactory

Subclass FileValidator for your own rules. The validator receives the file (or, for @UploadedFiles(), an array or an object of arrays), so narrow the type before using it:

src/books/cover.validator.ts
import { FileValidator } from '@nestjs/common';

export interface CoverValidatorOptions {
  minBytes: number;
}

type Upload = { size: number };

// isValid() receives one file here; with @UploadedFiles() it can also be an array or a record of arrays.
export class CoverValidator extends FileValidator<CoverValidatorOptions, Upload & { mimetype: string }> {
  isValid(file?: Upload | Upload[] | Record<string, Upload[]>): boolean {
    if (!file || Array.isArray(file) || !('size' in file) || typeof file.size !== 'number') return false;
    return file.size >= this.validationOptions.minBytes;
  }

  buildErrorMessage(): string {
    return `a cover must be at least ${this.validationOptions.minBytes} bytes`;
  }
}
Using it on an optional file
@Post('books/:id/cover-optional')
@UseInterceptors(FileInterceptor('cover'))
uploadOptionalCover(
  @UploadedFile(
    new ParseFilePipe({
      fileIsRequired: false,
      validators: [new CoverValidator({ minBytes: 8 })],
    }),
  )
  cover?: Express.Multer.File,
) {
  return { received: cover !== undefined };
}

Without a file the route answers 201 {"received":false}; with a 3-byte file it answers 400 {"message":"a cover must be at least 8 bytes", …​}. To change the error body, pass exceptionFactory; on Fastify the route below returns 422 {"reason":"Validation failed (current file type is text/plain, expected type is text/csv)"} for a text file posted to a CSV-only route:

new ParseFilePipe({
  exceptionFactory: (error) => new UnprocessableEntityException({ reason: error }),
  validators: [new FileTypeValidator({ fileType: 'text/csv', skipMagicNumbersValidation: true })],
});

When you use several validators in a reusable form, define the array once in a constant (for example fileValidators) and share it across routes. The same pipe works on arrays of files: pass a pipe built with ParseFilePipeBuilder to @UploadedFiles() and every validator runs against each file, as in .addFileTypeValidator({ fileType: /^image\/(png|jpeg|gif)$/ }) with FilesInterceptor('files', 5).

Pitfall: the pipe runs after the file is stored

The validation pipe is a parameter pipe, so it runs after the upload interceptor has already stored the file. With memory storage that costs only memory. With disk storage, a file that is rejected by ParseFilePipe stays on disk: in the Express run, a request rejected by CoverValidator with the 400 above left one file in the upload directory, whereas a request rejected by the interceptor itself (four files against maxCount 3) left none. Rely on limits and fileFilter (next sections) to reject early, and clean up rejected files in an exception filter or a scheduled sweep of the upload directory.

Options, limits and storage

The interceptors take the Multer option set on Express and the same set on Fastify. Both are listed here; the options you will use daily are storage, limits and fileFilter.

Option Meaning

dest

Folder for uploaded files, written under a random name. Without dest or storage, files are kept in memory in file.buffer.

storage

A storage engine: diskStorage({ destination, filename }) or memoryStorage(). Disk storage sets destination, filename and path on the file instead of buffer.

limits

An object, or a function that receives the request and returns one: fieldNameSize, fieldSize, fields, fileSize, files, parts, headerPairs.

fileFilter

(req, file, callback): call callback(null, true) to accept the file or callback(null, false) to skip it silently, or callback(error, false) to fail the request.

preservePath

Keep the full path supplied in the file name instead of only its base name.

defParamCharset

Character set used to decode the parameters of the multipart headers (the file name, for example).

Storage

On Express, diskStorage and memoryStorage come from the multer package (import { memoryStorage } from 'multer' and import multer from 'multer'; multer.diskStorage(…​) both work in the ESM layout). On Fastify they are exported from @nestjs/platform-fastify/multipart. Multer’s own diskStorage() and third-party Multer storage engines can be passed as storage to the Fastify interceptors unchanged, because they follow the same _handleFile() / _removeFile() contract.

The Fastify documentation shows disk storage with a timestamped name. Do not use the client’s originalname as the stored name without sanitizing it; the examples here generate a UUID and keep only the extension.

@Post('upload')
@UseInterceptors(
  FileInterceptor('file', {
    storage: diskStorage({
      destination: './uploads',
      filename: (req, file, callback) =>
        callback(null, `${Date.now()}-${file.originalname}`),
    }),
  }),
)
uploadFile(@UploadedFile() file: UploadedMultipartFile) {
  console.log(file.path);
}

Module-wide defaults: MulterModule and MultipartModule

To set defaults for every interceptor of a module instead of repeating options on each route, import MulterModule (Express) or MultipartModule (Fastify, 12.1). Both have register() and registerAsync(); registerAsync() supports useFactory (with imports and inject), useClass (a class implementing MulterOptionsFactory with createMulterOptions(), or MultipartOptionsFactory with createMultipartOptions()) and useExisting. MulterModule.registerAsync() also accepts extraProviders. The upload folder comes from configuration, never a literal path, so the same code runs in every environment:

src/books/covers-express.module.ts
import { randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MulterModule } from '@nestjs/platform-express';
import multer from 'multer';

@Module({
  imports: [
    ConfigModule.forRoot(),
    // Module-wide defaults for every upload interceptor of this module.
    MulterModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        storage: multer.diskStorage({
          destination: config.getOrThrow<string>('UPLOAD_DIR'),
          filename: (_req, file, cb) =>
            cb(null, `${randomUUID()}${extname(file.originalname)}`),
        }),
        limits: { fileSize: 1024 * 1024, files: 5 },
        fileFilter: (_req, file, cb) =>
          cb(null, !file.originalname.endsWith('.exe')),
      }),
    }),
  ],
  controllers: [CoversController],
})
export class CoversExpressModule {}

The Fastify module is the same with different imports and names:

src/books/covers-fastify.module.ts
import { randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { diskStorage, MultipartModule } from '@nestjs/platform-fastify/multipart';

@Module({
  imports: [
    ConfigModule.forRoot(),
    MultipartModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        storage: diskStorage({
          destination: config.getOrThrow<string>('UPLOAD_DIR'),
          filename: (_req, file, cb) =>
            cb(null, `${randomUUID()}${extname(file.originalname)}`),
        }),
        limits: { fileSize: 1024 * 1024, files: 5 },
      }),
    }),
  ],
  controllers: [CoversFastifyController],
})
export class CoversFastifyModule {}

With these modules, the gallery route stores its files on disk under UUID names (.png files in the upload folder), and a 1 MiB + 10 byte upload is rejected before reaching the handler with 413 {"message":"File too large","error":"Payload Too Large","statusCode":413} on both adapters. A file named x.exe is skipped by the fileFilter, so the handler receives no file and ParseFilePipe answers File is required (422). Note that fileFilter only sees the client-declared name and MIME type; it is a convenience filter, not a security control. Defaults can also be given statically:

MulterModule.register({ dest: './upload' });
MultipartModule.register({ dest: './upload' });

Limits on Fastify

@fastify/multipart applies stricter defaults than Multer, which limits nothing. Unless configured, the maximum file size (fileSize) is the Fastify bodyLimit (1 MiB) and the maximum number of parts (parts) is 1000. Larger files are rejected with 413 File too large. Raise the limits at the level that fits:

  • per route, with the limits option of the interceptor;

  • per module, with MultipartModule.register() or registerAsync();

  • for the whole application, with the multipart option of FastifyAdapter, which passes options to the plugin itself.

Limits set on a route are merged over the ones from MultipartModule key by key, and both are merged over the application-wide ones. The only exception is fieldNameSize, which the interceptors do not read from the plugin options.

const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter({
    multipart: { limits: { fileSize: 10 * 1024 * 1024 } },
  }),
);

A 2 MiB upload to a route without module or route limits fails with 413 File too large under the defaults and succeeds (201 {"size":2097152}) with the adapter option set to 3 MiB, while a route-level limits: { fileSize: 16 } rejects a 100-byte file in both cases. The module-wide 1 MiB limit above still wins over a 10 MiB adapter-wide option, which is the merge order described above.

How the Fastify plugin gets registered

The multipart option of FastifyAdapter controls registration of @fastify/multipart:

Value Behavior

not set (default)

The adapter registers the plugin when an upload interceptor is used, unless it is already registered.

an options object (FastifyMultipartOptions, for example limits)

The adapter registers the plugin with those options when the application initializes.

true

Same, with the plugin’s default options.

false

The adapter never registers it, for applications that register it themselves or parse multipart another way. The interceptors still require the plugin, and the application fails to start if it is not registered.

Set the option to true (or an options object) when an upload interceptor is instantiated outside dependency injection, such as new (FileInterceptor('file'))() passed to app.useGlobalInterceptors(), or when upload interceptors live only in lazy-loaded modules that load after start-up, since plugins can no longer be registered then. To register the plugin yourself, pass it to app.register(); your options take precedence over the multipart option:

import multipart from '@fastify/multipart';

const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter(),
);
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });

Registering the plugin directly on the Fastify instance (app.getHttpAdapter().getInstance().register(multipart)), or passing a dynamic import() of it to app.register(), only works before app.init() (which app.listen() calls). After that, Fastify fails with FST_ERR_DEC_ALREADY_PRESENT. With multipart: false the application refuses to start with an error stating that the interceptors require the plugin, which the adapter registers unless multipart is false.

Differences from Multer on Fastify

Besides the default limits, the Fastify interceptors differ from Multer in these ways, which matter when migrating or sharing code between adapters:

  • A text field of exactly fieldSize bytes is accepted, while Multer rejects it.

  • Field names that could pollute a prototype (constructor, or names with a __proto__, constructor or prototype segment, such as user[constructor]) fail with 400 Invalid field name. Multer accepts them. A form field named constructor is enough to trigger it.

  • Text fields sent with Content-Type: application/json arrive parsed, and invalid JSON fails the request with a 400. Malformed part header lines are ignored, where Multer fails the request.

  • fileFilter functions and storage engines receive the Fastify request instead of the Express one.

  • The plugin’s attachFieldsToBody option is not supported.

  • Multer’s fieldNestingDepth, fieldArrayIndexLimit, highWaterMark, fileHwm, defCharset and streamHandler options are not supported by the interceptors.

Streaming uploads

All the interceptors above store every file, in memory or on disk, before the handler runs. That is wrong for a large upload that should go straight to its destination (object storage, a processing pipeline): the file would be stored twice, and memory storage holds all of it in RAM.

Fastify: FileStreamInterceptor

On Fastify, FileStreamInterceptor(fieldName, options?) hands the handler a MultipartFileStream, a file with a stream property instead of buffer. Its options are the same as for the other interceptors except dest and storage.

Streaming a manuscript to disk without buffering it
import { randomUUID } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  FileStreamInterceptor,
  type MultipartFileStream,
} from '@nestjs/platform-fastify/multipart';

@Controller()
export class ManuscriptsController {
  constructor(private readonly config: ConfigService) {}

  @Post('books/:id/manuscript')
  @UseInterceptors(
    FileStreamInterceptor('manuscript', { limits: { fileSize: 5 * 1024 * 1024 } }),
  )
  async uploadManuscript(
    @UploadedFile() file: MultipartFileStream,
    @Body() body: Record<string, string>,
  ) {
    const path = join(this.config.getOrThrow<string>('UPLOAD_DIR'), randomUUID());
    await pipeline(file.stream, createWriteStream(path));
    return { originalname: file.originalname, body };
  }
}
curl -X POST http://localhost:3000/books/7/manuscript \
  -F 'title=Draft' \
  -F 'manuscript=@manuscript.bin'
{ "originalname": "manuscript.bin", "body": { "title": "Draft" } }

Because the file is never buffered, a few rules apply:

  • Text fields must precede the file in the form. The ones sent before it are available in @Body(); the parts that follow the file are not parsed. The title field above worked only because it comes first.

  • Only one file is accepted, in the given field. A file in any other field fails the request with 400 Unexpected file field (body: {"message":"Unexpected file field - other", …​}).

  • The file size is not known up front, so MaxFileSizeValidator does not apply, and neither does the magic-number check of FileTypeValidator (set skipMagicNumbersValidation: true to compare the client-provided type only). The fileSize limit is still enforced: once exceeded, the stream errors with a PayloadTooLargeException; a 5 MiB + 10 byte upload to the route above answers 413 File too large.

  • Whatever the handler leaves unread is discarded before the response is sent, so the client can finish uploading. When the handler returns a StreamableFile (which may be the upload itself), the rest of the upload is discarded once the response has been sent. A handler that responds with a StreamableFile of something else should read the upload first; otherwise a client still uploading over a connection that is not kept alive may fail with EPIPE or ECONNRESET.

Express: read the request stream

Multer has no streaming mode and the Nest docs describe no Express counterpart of FileStreamInterceptor. If you must stream on Express, send the file as the request body with a content type that Nest’s default parsers leave alone: only json and urlencoded are registered by default, so a request with Content-Type: application/octet-stream reaches the handler as an unread stream, and @Req() is a Node.js readable you can pipe. This is plain Node.js streams, not a Nest feature; it trades the form fields and the interceptor’s limits for simplicity, so enforce a size limit yourself (for example with a guard on Content-Length, or by counting bytes in a Transform).

src/books/stream-upload.controller.ts (Express)
import { createWriteStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { Controller, Post, Req } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';

@Controller('books')
export class StreamUploadController {
  constructor(private readonly config: ConfigService) {}

  @Post(':id/manuscript')
  async uploadManuscript(@Req() req: Request) {
    const path = join(this.config.getOrThrow<string>('UPLOAD_DIR'), 'manuscript.bin');
    await pipeline(req, createWriteStream(path));
    return { bytesWritten: (await stat(path)).size };
  }
}
curl -X POST http://localhost:3000/books/7/manuscript \
  -H 'Content-Type: application/octet-stream' \
  --data-binary @manuscript.bin
{ "bytesWritten": 300000 }

The fixed file name above is only for the demonstration; a real route should generate a unique name per request. A multipart/form-data streaming alternative on Express is a streaming parser such as Busboy used directly, which is outside what Nest documents.

Streaming files down with StreamableFile

To send a file from a handler you could pipe a stream into the response object with @Res():

@Controller('file')
export class FileController {
  @Get()
  getFile(@Res() res: Response) {
    const file = createReadStream(join(process.cwd(), 'package.json'));
    file.pipe(res);
  }
}

With this approach, which the reference book uses, the handler takes over the response: post-controller interceptor logic no longer runs, the response is not serialized, and errors are your problem. Return a StreamableFile instead and Nest pipes it into the response for you, on both platforms. Streaming files applies to HTTP applications only, not to GraphQL or microservice applications.

StreamableFile (from @nestjs/common) holds the content: its constructor accepts a Readable stream or a Uint8Array (including a Buffer), and an optional options object with type, disposition and length.

Option Meaning

type

The Content-Type header. The default is application/octet-stream.

disposition

The Content-Disposition header, for example attachment; filename="catalog.json".

length

The Content-Length header. For a Uint8Array it defaults to its size; for streams set it yourself if you know the size.

Neither ClassSerializerInterceptor nor StandardSchemaSerializerInterceptor serializes StreamableFile responses (see Serialization), so it is safe to leave them bound globally. The controller below works on both adapters, with ClassSerializerInterceptor bound to it:

src/books/downloads.controller.ts
import { createReadStream } from 'node:fs';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import {
  ClassSerializerInterceptor,
  Controller,
  Get,
  Header,
  Param,
  StreamableFile,
  UseInterceptors,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Controller('downloads')
@UseInterceptors(ClassSerializerInterceptor)
export class DownloadsController {
  constructor(private readonly config: ConfigService) {}

  // Stream + options object: Content-Type, Content-Disposition, Content-Length.
  @Get('catalog')
  catalog(): StreamableFile {
    const path = join(this.config.getOrThrow<string>('UPLOAD_DIR'), 'catalog.json');
    return new StreamableFile(createReadStream(path), {
      type: 'application/json',
      disposition: 'attachment; filename="catalog.json"',
    });
  }

  // Uint8Array/Buffer: length defaults to its size.
  @Get('buffer')
  fromBuffer(): StreamableFile {
    return new StreamableFile(Buffer.from('hello'), { type: 'text/plain' });
  }

  // Static headers via @Header(); the body type defaults to application/octet-stream.
  @Get('static/:name')
  @Header('Cache-Control', 'private, max-age=60')
  @Header('X-Content-Type-Options', 'nosniff')
  staticHeaders(@Param('name') _name: string): StreamableFile {
    return new StreamableFile(Readable.from(['chunk-1,', 'chunk-2']));
  }
}
curl -i http://localhost:3000/downloads/catalog
HTTP/1.1 200 OK
Content-Type: application/json
Content-Disposition: attachment; filename="catalog.json"

Both adapters return application/json with the attachment disposition and no Content-Length for the stream (so the response is chunked), a Content-Length of 5 for Buffer.from('hello'), and application/octet-stream plus the two @Header() values for the third route. @Header() is documented in Controllers & Routing.

Headers computed at runtime

When headers depend on the request, inject the response with @Res({ passthrough: true }). Without passthrough: true Nest hands the response over to you and would not send the returned StreamableFile. The response object differs per platform:

Express
import type { Response } from 'express';

@Get('dynamic')
dynamic(@Res({ passthrough: true }) res: Response): StreamableFile {
  const file = createReadStream(join(this.config.getOrThrow<string>('UPLOAD_DIR'), 'catalog.json'));
  res.set({
    'Content-Type': 'application/json',
    'Content-Disposition': 'attachment; filename="catalog-dynamic.json"',
  });
  return new StreamableFile(file);
}
Fastify
import type { FastifyReply } from 'fastify';

@Get('dynamic')
dynamic(@Res({ passthrough: true }) res: FastifyReply): StreamableFile {
  const file = createReadStream(join(this.config.getOrThrow<string>('UPLOAD_DIR'), 'catalog.json'));
  res.header('Content-Type', 'application/json');
  res.header('Content-Disposition', 'attachment; filename="catalog-dynamic.json"');
  return new StreamableFile(file);
}

Both versions return the catalog-dynamic.json disposition. The Express snippet is from the official page; the Fastify one replaces res.set() with res.header().

Stream errors: Express and Fastify differ

With the Express adapter, if the stream emits an error before any data has been sent, the client receives 400 Bad Request with the error message as its body (for a missing file, the message is the ENOENT text, which also leaks the server path, so replace it). If data has already been sent, the response is ended. Customize this with setErrorHandler(); setErrorLogger() replaces the function that logs errors emitted by the response stream.

Express only
@Get('report')
getReport(): StreamableFile {
  const file = createReadStream(join(process.cwd(), 'report.pdf'));
  return new StreamableFile(file).setErrorHandler((err, res) => {
    if (res.headersSent) {
      res.end();
      return;
    }
    res.statusCode = 404;
    res.send('Report not found');
  });
}

With a missing report.pdf this answers 404 Report not found, while the same handler without setErrorHandler() answers 400 with the ENOENT message. With the Fastify adapter, a stream error is handled by the exceptions layer, so the default response is 500 {"statusCode":500,"message":"Internal server error"}; error handler and logger apply to the Express adapter. The portable approach is to check that the file exists, with stat() or by opening it, before creating the StreamableFile, and throw NotFoundException yourself.

Fastify can send streams without stream.pipe(res), so StreamableFile is not strictly required there; Nest supports it on both platforms, so code that returns it keeps working if you switch adapters.

Pitfalls when downloading

  • Returning a StreamableFile does not add range request (Range) support, ETag or caching headers; add @Header('Cache-Control', …​) or serve static assets with the static-files module instead (see MVC, Static Files & Compression).

  • A user-supplied file name in Content-Disposition or a user-supplied path in createReadStream() is a header-injection and path-traversal risk. Map an identifier to a stored path server-side, and never concatenate request input into a path.

  • Do not combine @Res() without passthrough: true with a returned StreamableFile: the returned value is ignored.

Raw body for signature verification

One of the most common reasons to read the raw request body is webhook signature verification: an HMAC is computed over the exact bytes the sender transmitted, so re-serializing the parsed JSON (different whitespace, key order) breaks the check. Nest can keep the original bytes next to the parsed body.

Piece What it does

rawBody: true

Application option of NestFactory.create(). Works only while the built-in global body parser middleware is enabled; do not pass bodyParser: false.

RawBodyRequest<T>

A convenience type that adds a rawBody field (a Buffer) to the request type T: Request from express or FastifyRequest from fastify.

app.useBodyParser(type, options)

Registers an additional parser ('text' on Express, 'text/plain' on Fastify). By default only the JSON and URL-encoded parsers exist. It respects the rawBody option. Requires the platform-specific application type (NestExpressApplication or NestFastifyApplication).

Enable it when creating the application:

Express
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module.js';

const app = await NestFactory.create<NestExpressApplication>(AppModule, {
  rawBody: true,
});
await app.listen(process.env.PORT ?? 3000);
Fastify
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';

const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter(),
  { rawBody: true },
);
await app.listen(process.env.PORT ?? 3000);

The following controller verifies an HMAC-SHA256 signature in constant time. The signing key comes from ConfigService, and the controller types the request loosely so the same code runs on both adapters (in an adapter-specific controller, type it with RawBodyRequest<Request> or RawBodyRequest<FastifyRequest>).

src/payments/webhook.controller.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
  Controller,
  Headers,
  HttpCode,
  Post,
  type RawBodyRequest,
  Req,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Controller('webhooks')
export class WebhookController {
  constructor(private readonly config: ConfigService) {}

  @Post('payments')
  @HttpCode(200)
  receive(
    @Req() req: RawBodyRequest<{ body: unknown }>,
    @Headers('x-signature') signature: string | undefined,
  ) {
    const raw = req.rawBody;
    if (!raw || !signature) throw new UnauthorizedException('missing body or signature');

    const expected = createHmac('sha256', this.config.getOrThrow<string>('WEBHOOK_SIGNING_KEY'))
      .update(raw)
      .digest();
    const received = Buffer.from(signature, 'hex');
    if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
      throw new UnauthorizedException('invalid signature');
    }
    return { verified: true, parsed: req.body, rawLength: raw.length };
  }
}

Source: NestJS documentation: Raw body; the HMAC check is ordinary Node.js node:crypto.

A body sent as { "event": "paid",\n "amount": 12 } with its real signature returns 200 {"verified":true,"parsed":{"event":"paid","amount":12},"rawLength":35} on both adapters (the extra spaces and the newline are exactly what re-serialization would lose), and a different body with the same signature returns 401 {"message":"invalid signature","error":"Unauthorized","statusCode":401}. Always compare the digests with timingSafeEqual after checking that the lengths match, and reject a request without a signature before computing anything.

Registering other parsers and raising body limits

Raw body is about the body parsed by the built-in parsers. To accept another content type, register a parser; to accept larger JSON or form bodies, raise the limit (Express default 100 kB, Fastify default 1 MiB):

Express
app.useBodyParser('text');
app.useBodyParser('json', { limit: '10mb' });
Fastify
app.useBodyParser('text/plain');
const bodyLimit = 10_485_760; // 10MiB
app.useBodyParser('application/json', { bodyLimit });

With the larger limit, a 2 MiB JSON body is accepted and reaches route validation. Body limits are separate from upload limits: Multer and @fastify/multipart read the request themselves, so raising the JSON limit does not raise the maximum file size, and on Fastify the file limit defaults to the body limit only when no fileSize limit is set. With the same bootstrap, a 2 MiB upload still fails with 413 because the module-level fileSize limit applies. The platform sections of a production setup, including bodyLimit for the whole Fastify server, are covered on Fastify & HTTP Platforms.

Testing uploads

Supertest builds multipart requests with .attach() and .field(), so the routes above are testable without a browser:

test/covers.e2e-spec.ts (excerpt)
const res = await request(app.getHttpServer())
  .post('/books/7/cover')
  .field('caption', 'First edition')
  .attach('cover', pngBuffer, { filename: 'cover.png', contentType: 'image/png' });

expect(res.status).toBe(201);
expect(res.body.caption).toBe('First edition');

Use a real file header for type checks: FileTypeValidator reads magic numbers, so a buffer of arbitrary bytes with an image/png content type fails as it would in production. For Fastify, call app.getHttpAdapter().getInstance().ready() after app.init() before issuing requests, and set UPLOAD_DIR to a temporary directory created by the test.

Pitfalls

  • The field name in the form must match fieldName. A mismatch yields Unexpected file field, not "file missing".

  • Multer processes only multipart/form-data. JSON or form-encoded bodies do not populate @UploadedFile().

  • FileInterceptor() may not be compatible with third-party cloud providers such as Google Firebase, according to the official page. Upload to your own endpoint and move the file to cloud storage from there.

  • Memory storage keeps entire files in RAM: always set a fileSize limit, and prefer disk storage or streaming for large files. Remember that `FileTypeValidator’s magic-number check needs the buffer.

  • Pipes run after storage, so disk files that fail validation remain on disk; clean up.

  • On Fastify, parts defaults to 1000 and fileSize to the body limit; a 2 MiB file that works on Express can return 413 after a switch of adapter.

  • Do not trust file.mimetype or file.originalname as security properties: both are client-supplied, and the original name may contain path segments or control characters. Generate the stored name and re-check the content type, ideally by magic number.

  • Serve uploaded files from a separate path or domain with Content-Disposition: attachment and X-Content-Type-Options: nosniff when they are user content, so an uploaded HTML file cannot run in your origin.

  • Where the stored files should live in object storage rather than on local disk, see i18n, Mail and File Storage for the pre-1.0 @nestjs/storage package.

What changed recently

  • NestJS 12.1.0 (2026-09-23) added file upload interceptors for Fastify backed by @fastify/multipart (pull request 17835): MultipartModule, MultipartOptionsFactory and createMultipartOptions() (the counterparts of the Multer API), FileStreamInterceptor() for streaming uploads, and the multipart option of FastifyAdapter. The Multer-based interceptors work only with Express, so on Fastify before 12.1 you had to register @fastify/multipart and read the multipart parts yourself.

  • The core packages became ESM-only in NestJS 12. In an ESM project, import relative files with the .js extension and, when you need Multer’s diskStorage() directly, import multer as a default import. The npm release 12.1.2 is the version these examples ran against.

  • @Body(), @Query(), @Param() and @RawBody() accept a Standard Schema schema option since 12.0 (see Pipes & Validation); text fields that accompany a file can be validated with it or with a DTO and ValidationPipe, while files are validated with ParseFilePipe.

Where the book is outdated

The reference book, published in January 2025 (see the section bibliography), covers uploads on Express with Multer only and downloads by piping a read stream into @Res(). Today:

  • Return a StreamableFile instead of using @Res() and pipe: interceptors and the exception layer keep working, headers are set with options, @Header() or a pass-through response, and the same code runs on Express and Fastify.

  • Fastify applications no longer need a hand-registered multipart plugin or a different programming model: since 12.1 the same interceptors, pipes and decorators work with @nestjs/platform-fastify/multipart.

  • Validation belongs in ParseFilePipe or ParseFilePipeBuilder with MaxFileSizeValidator and FileTypeValidator, which checks the content’s magic number, not just the header the client declared.

  • Signature verification for webhooks uses rawBody: true and RawBodyRequest, not a hand-written body-parsing middleware.

Summary

  • Uploads are interceptor plus pipe: FileInterceptor() and friends parse and store, @UploadedFile() reads, ParseFilePipe validates; guards run first.

  • Choose the interceptor by shape: one file, many files in a field, files in named fields, any files, or none.

  • Set limits and storage once with MulterModule / MultipartModule, reading paths from ConfigService; override per route when needed.

  • Magic-number type checks need memory storage; pipes run after storage, so clean up rejected disk files.

  • Fastify (12.1+) gets the same API from @nestjs/platform-fastify/multipart, with default limits (1 MiB), prototype-safe field names and FileStreamInterceptor() for true streaming.

  • Download with StreamableFile and its type, disposition and length options; stream error handling differs between Express (setErrorHandler()) and Fastify (exceptions layer).

  • Use rawBody: true with RawBodyRequest and timingSafeEqual for signed webhooks.

To turn uploaded images and videos into optimized variants, BlurHash placeholders and streaming renditions with sharp and BullMQ, see Node.js and Rust Image and Video Libraries.