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

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:
Reads the multipart request
Extracts the file from the body
Saves it somewhere (like your disk or memory)
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:
Client sends multipart/form-data:
Text fields (
name
,email
, etc.)File field (
file
) with a real file attached
Multer (via
FileInterceptor
) intercepts the request:Parses the body and file stream
Stores the file based on your
diskStorage()
configAdds the parsed file as
req.file
Adds text fields as
req.body
NestJS injects the parsed data:
@UploadedFile()
→req.file
@Body()
→req.body
Then your controller runs
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 namedfile
in the request”Saves the file to
./uploads
usingdiskStorage
Added
ValidationPipe
to check if the incoming form data is valid withclass-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.
Subscribe to my newsletter
Read articles from Adheeb Anvar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
