Mastering File Uploads in NestJS: Pitfalls, Fixes & What Actually Worked

Adheeb AnvarAdheeb Anvar
8 min read

I thought adding a file upload would be a 5-minute task. Instead, I got hit with undefined files, silent errors, and routes clashing like dominos.If you’ve ever tried implementing file uploads in a NestJS project

It looks simple on paper: add @UploadedFile(), use Multer, store the file.

But real-world file uploading? That’s a different beast. Especially when you start dealing with JWT-protected routes, custom filenames, Postgres integration, and sending both files and text data through Postman.

In this blog, i will help you go through the steps that is required for basic file uploading using the so called ‘Multer‘ so that you don’t have to break a sweat and could implement in your project as easy as possible

Setup & Tools

  • NestJS with @nestjs/platform-express

  • Multer for file handling

  • Drizzle ORM with PostgreSQL

  • Authentication with guards

  • Postman for testing uploads

What i wanted to build

My goal was to allow authenticated users (like agents) to upload a file (say, a company registration doc or a profile image). The file should:

  • Be saved in the local uploads/ directory with a unique name.

  • Be logged in the uploads table using Drizzle ORM.

  • Be linked to the user who uploaded it.

  • Be accessible via a static URL, served properly in the backend.

But for now,let’s start small and understand the basics of File Uploading,so that you too can get a good dopamine rush on having done something great in your life(as if i have created the next Facebook).

Understanding what happens when we upload files

1) Understanding Multipart/Form-Data

Files are send through http requests in the form of multipart/form data. It’s a special format used to send files and text fields together in a single HTTP request — like when you're filling out a form and attaching a file.

Plain json cannot carry files since files are binary(not just text),therefore we use multiform/part data

For eg: Imagine you are uploading a profile:
name: ”ronaldo”
email: ”ronaldo@suii.com”
file: photo

in multipart/form data,it will be sent like

------WebKitFormBoundary
Content-Disposition: form-data; name="name"

ronaldo
------WebKitFormBoundary
Content-Disposition: form-data; name="email"

ronaldo@suii.com
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="photo.png"
Content-Type: image/png

(binary file data here)
------WebKitFormBoundary--

This is what our browser send when we attach a file and submit a form

2) What Is Multer and Why NestJS Uses It

Now Multer basically is a middleware between incoming http request and route handlers,it reads the incoming request and pulls out the actual file from it.

When someone uploads a file through a form, Multer:

  1. Reads the multipart request

  2. Extracts the file from the body

  3. Saves it somewhere (like your disk or memory)

  4. Makes it available to your route handler

3) How @FileInterceptor Works Under the Hood

This is a nestJs wrapper around multer,which means that it tells multer to check for something called as “file“,we got this from the argument that we will pass in @FileInterceptor like this:

@UseInterceptor(@FileInterceptor(‘file’))

so multer is activated by @FileInterceptor and checks for ‘file’ in the multipart/form data. Then multer does it work and after saving it in disk or memory, the metadata which includes about the name,mimetype,path,etc will be stored in the request

4) @UploadedFile

Now this decorator takes up the metadata that was stored in request and it will inject it into a controller method parameter

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
  console.log(file.originalname); // → "myphoto.png"
  console.log(file.path);         // → "uploads/file-123.png"
}
//this is just an example code

Now to sum it up:

  1. Client sends multipart/form-data:

    • Text fields (name, email, etc.)

    • File field (file) with a real file attached

  2. Multer (via FileInterceptor) intercepts the request:

    • Parses the body and file stream

    • Stores the file based on your diskStorage() config

    • Adds the parsed file as req.file

    • Adds text fields as req.body

  3. NestJS injects the parsed data:

    • @UploadedFile()req.file

    • @Body()req.body

    • Then your controller runs

  4. You can now use the file:

    • Save metadata to DB

    • Serve it statically

    • Validate it (size, mimetype, etc.)

Now let’s get to the fun stuff

Implementing the code

This is where things got practical. After figuring out what Multer does and how it works with FileInterceptor, I jumped into the actual implementation.

i have created tables that were interlinked together,so that whenever one of the records are deleted,it is deleted in all the other tables as well. For this i have used something called as transaction(tx).

A tx in Drizzle (or any DB) is like a "group promise" — either all DB operations inside it succeed together, or none of them happen at all.
So when you insert a user and their verification record together:

await db.transaction(async (tx) => {
  const [user] = await tx.insert(users).values({...}).returning();
  await tx.insert(verifications).values({ userId: user.id });
});

If one fails, both get cancelled — protecting your data from weird half-saved states.

Now, i created a simple register endpoint where when a user is inserted,file must also be uploaded with it

//auth.service.ts
async simpleRegister(data, file: Express.Multer.File) {
  return await this.db.transaction(async (tx) => {
    const hashed = await hashPassword(data.password);

    // 1. Insert user
    const [user] = await tx.insert(schema.users).values({
      name: data.name,
      email: data.email,
      role: "user",
    }).returning();

    // 2. Save uploaded file
    await tx.insert(schema.uploads).values({
      userId: user.id,
      filename: file.originalname,
      filepath: file.path,
      url: `/uploads/${file.filename}`,
      type: file.mimetype,
      size: file.size,
      uploadedAt: new Date(),
    });

    return { message: "User registered with file uploaded!" };
  });
}

Now that the service layer handles registration and file upload, the controller's job is to connect HTTP requests to logic cleanly.

//auth.controller.ts
import {
  Controller,
  Post,
  Body,
  UseInterceptors,
  UploadedFile,
  UsePipes,
  ValidationPipe,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { extname } from "path";
import { SimpleRegisterDto } from "./dto/simple-register.dto";
import { AuthService } from "./auth.service";

@Controller("auth")
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UsePipes(new ValidationPipe({ transform: true }))
  @UseInterceptors(
    FileInterceptor("file", {
      storage: diskStorage({
        destination: "./uploads",
        filename: (req, file, cb) => {
          const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
          const ext = extname(file.originalname);
          cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
        },
      }),
    })
  )
  @Post("register/simple")
  async simpleRegister(
    @Body() data: SimpleRegisterDto,
    @UploadedFile() file: Express.Multer.File
  ) {
    return this.authService.simpleRegister(data, file);
  }
}

You may get overwhelmed by the Code,but here’s what it does:

  • Listens to POST /auth/register/simple

  • Uses FileInterceptor('file') to tell NestJS: “Look for a file named file in the request”

  • Saves the file to ./uploads using diskStorage

  • Added ValidationPipe to check if the incoming form data is valid with class-validator.

  • Extracts:

    • Text fields using @Body()

    • File using @UploadedFile()

  • Then passes everything to the service

  • This made sure the frontend could send both form data and a file in one single request — and the backend handled it smoothly.

This is the authModule:

// src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { PassportModule } from "@nestjs/passport";
import { JwtModule } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { LocalStrategy } from "./local.strategy";
import { JwtStrategy } from "./jwt.strategy";

@Module({
  imports: [
    PassportModule,//authentication

    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.get("JWT_SECRET"),
        signOptions: { expiresIn: config.get("JWT_EXPIRES_IN") },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}

ServerStaticModule (Opening the Upload Door)

Uploading a file to your server is only half the job—the other half is letting users (or the frontend) see or access that file. NestJS doesn’t expose your uploads/ folder to the public by default. That’s where ServeStaticModule comes in. When we provide the url of uploaded file in the browser, that file becomes accessible. So if a file was saved as uploads/my-pic.png, it becomes accessible at:

http://localhost:3000/uploads/my-pic.png

Without this, even if the upload worked perfectly, your app would keep saying 404 Not Found when you tried to access the file.

To implement this in my project,i set this up in my root app module:

import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { DatabaseModule } from "@repo/database";
import { AuthModule } from "./modules/auth/auth.module";
import { ServeStaticModule } from "@nestjs/serve-static";
import { dirname, join, resolve } from "path";


const uploadsPath = resolve(__dirname, "..", "..", "uploads");

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: uploadsPath,
      serveRoot: "/uploads",
    }),
    ConfigModule.forRoot({ isGlobal: true }),
    DatabaseModule.forRoot({ isGlobal: true }),
    AuthModule,
  ],
})
export class AppModule {}

Roadblocks & Fixes

1) Multer fails silently because ./uploads folder doesn't exist.
Fix: Always create an upload folder and then specify the path in @FileInterceptor, because @FileInterceptor will not automatically create a folder,we need to manually create it so that it acts as a path

2)Uploaded file URL returned 404
Cause: ServeStaticModule wasn’t configured,
fix: This is what i warned about, even if the uploading part happens,if serverstaticmodule is not set up,then 404 error will appear

3) Zod Validation Fails with File Upload
Cause:Used Zod instead of class-validator, For file uploading purposes,always use class-validator instead of zod for schema validation

4)File Upload and JSON Sent Separately
Cause: Sent two separate requests — one with the file and another with JSON
Fix:This is why i told to learn the basics like what multipart/form data is, send both file and text fields in a single request

Takeaways: What I Have Learned

Here’s what i understood:

  • FileInterceptor isn't just syntax—it's how you talk to Multer. If the name doesn't match, nothing works. Simple as that.

  • Multer does the heavy lifting behind the scenes—saving the file, assigning metadata, and handing it off to request.file before controller.

  • @UploadedFile() gives you the file details, not the raw file, but the parsed data: name, path, size,etc

  • Serving files? Not automatic. You have to plug in ServeStaticModule, or your uploads are basically locked in.

0
Subscribe to my newsletter

Read articles from Adheeb Anvar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Adheeb Anvar
Adheeb Anvar