How to Handle File Uploads in NestJS (With Image Thumbnails and S3)
A complete walkthrough of building file uploads in a NestJS app — accept multipart uploads, store files on disk or S3, associate them with database entities, and auto-generate image thumbnails with Sharp.
Khatab Wedaa
Software Engineer · Nestbolt
File uploads sound simple until you actually build them. You need to accept a multipart form, validate the file type and size, store it somewhere durable, save a record in the database so you can retrieve it later, and generate thumbnails for the image variants your frontend needs. Then you need it all to work the same in development against the local filesystem and in production against S3.
This guide walks through that full path in NestJS — from a raw @UploadedFile() in a controller to a structured media table with auto-generated WebP thumbnails. We'll use @nestjs/platform-express (which wraps Multer), TypeORM for persistence, and Sharp for image processing. All code is TypeScript and works on NestJS 10+.
What you'll build
By the end of this tutorial, your NestJS app will support:
POST /posts— create a post with an attached image upload- A
mediatable that tracks every file with metadata (mime type, size, disk, custom properties) - Auto-generated thumbnails (WebP, 300×300) saved alongside each upload
- A pluggable storage layer that points at the local filesystem in dev and S3 in production
Prerequisites
- Node.js 18 or later
- A NestJS 10+ project with TypeORM configured (PostgreSQL, MySQL, or SQLite all work)
- Basic familiarity with controllers and services
1. Install dependencies
npm install @nestjs/platform-express multer sharp
npm install --save-dev @types/multerWhat each package does:
@nestjs/platform-express— ships with NestJS by default and includes Multer integration viaFileInterceptor.multer— the multipart parser. NestJS'sFileInterceptoris a thin wrapper over it.sharp— high-performance image processing for thumbnails and format conversion.@types/multer— types forExpress.Multer.Fileso@UploadedFile()is properly typed.
For S3 support later in the article:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner2. Build a minimal upload endpoint
Start with the simplest possible upload. NestJS exposes FileInterceptor to parse a single field from a multipart body:
// src/posts/posts.controller.ts
import {
Body,
Controller,
Post,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
@Controller("posts")
export class PostsController {
@Post()
@UseInterceptors(FileInterceptor("image"))
create(
@Body("title") title: string,
@UploadedFile() file: Express.Multer.File,
) {
return {
title,
fileName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
};
}
}Test it:
curl -X POST http://localhost:3000/posts \
-F "title=My First Post" \
-F "image=@/path/to/photo.jpg"You should see the file metadata echoed back. The file is in file.buffer — but it's still in memory. We haven't saved anything yet.
3. Add validation
Never trust the client. Multer happily accepts any file at any size unless you tell it otherwise. Pass options to FileInterceptor:
@Post()
@UseInterceptors(
FileInterceptor("image", {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (_req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.mimetype)) {
return cb(new BadRequestException("Unsupported file type"), false);
}
cb(null, true);
},
}),
)
create(
@Body("title") title: string,
@UploadedFile() file: Express.Multer.File,
) { /* ... */ }Multer's fileFilter runs before the body is fully buffered, so a 200 MB upload of the wrong MIME type is rejected without consuming memory. The limits.fileSize cap aborts oversized uploads with a clear MulterError: File too large.
4. Persist files to the filesystem
In-memory buffers are useless once the request ends. We need to write the file to disk and remember where it went. Build a small storage service:
// src/storage/local-storage.service.ts
import { Injectable } from "@nestjs/common";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
@Injectable()
export class LocalStorageService {
private readonly root = path.resolve("./uploads");
async put(buffer: Buffer, originalName: string): Promise<{
key: string;
diskPath: string;
}> {
const id = randomUUID();
const ext = path.extname(originalName);
const key = `${id}${ext}`;
const diskPath = path.join(this.root, key);
await mkdir(this.root, { recursive: true });
await writeFile(diskPath, buffer);
return { key, diskPath };
}
}The UUID prefix prevents filename collisions and stops users from guessing each other's URLs. We keep the original extension so MIME type detection in the browser still works.
5. Track every upload in the database
Files on disk are easy to lose track of. Create a media table that joins every upload to its owning entity:
// src/media/media.entity.ts
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("media")
@Index(["modelType", "modelId"])
export class Media {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ name: "model_type" })
modelType!: string;
@Column({ name: "model_id" })
modelId!: string;
@Column({ name: "collection_name", default: "default" })
collectionName!: string;
@Column({ name: "file_name" })
fileName!: string;
@Column({ name: "storage_key" })
storageKey!: string;
@Column({ name: "mime_type" })
mimeType!: string;
@Column({ type: "bigint" })
size!: number;
@Column({ type: "jsonb", default: {} })
customProperties!: Record<string, unknown>;
@CreateDateColumn({ name: "created_at" })
createdAt!: Date;
@UpdateDateColumn({ name: "updated_at" })
updatedAt!: Date;
}The modelType + modelId polymorphic pair is the trick that lets a single media table serve any entity in your app — posts, users, comments, products. You query it with where({ modelType: "Post", modelId: post.id }).
6. Wire it together
The service ties storage and persistence into a single attach() method:
// src/media/media.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { LocalStorageService } from "../storage/local-storage.service";
import { Media } from "./media.entity";
interface AttachInput {
modelType: string;
modelId: string;
collection?: string;
buffer: Buffer;
fileName: string;
mimeType: string;
customProperties?: Record<string, unknown>;
}
@Injectable()
export class MediaService {
constructor(
@InjectRepository(Media)
private readonly mediaRepo: Repository<Media>,
private readonly storage: LocalStorageService,
) {}
async attach(input: AttachInput): Promise<Media> {
const { key } = await this.storage.put(input.buffer, input.fileName);
const media = this.mediaRepo.create({
modelType: input.modelType,
modelId: input.modelId,
collectionName: input.collection ?? "default",
fileName: input.fileName,
storageKey: key,
mimeType: input.mimeType,
size: input.buffer.length,
customProperties: input.customProperties ?? {},
});
return this.mediaRepo.save(media);
}
getMediaForEntity(modelType: string, modelId: string, collection = "default") {
return this.mediaRepo.find({
where: { modelType, modelId, collectionName: collection },
order: { createdAt: "ASC" },
});
}
}Update the controller to use it:
@Post()
@UseInterceptors(FileInterceptor("image", uploadOptions))
async create(
@Body("title") title: string,
@UploadedFile() file: Express.Multer.File,
) {
const post = await this.postRepo.save({ title });
const media = await this.mediaService.attach({
modelType: "Post",
modelId: post.id,
collection: "images",
buffer: file.buffer,
fileName: file.originalname,
mimeType: file.mimetype,
customProperties: { alt: title },
});
return { post, mediaId: media.id };
}7. Generate thumbnails with Sharp
Frontends rarely want the original 4 MB JPEG straight from the camera. Add a thumbnail generator:
// src/media/thumbnail.service.ts
import { Injectable } from "@nestjs/common";
import sharp from "sharp";
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
@Injectable()
export class ThumbnailService {
private readonly root = path.resolve("./uploads/conversions");
async generate(
originalBuffer: Buffer,
storageKey: string,
): Promise<string> {
const thumbBuffer = await sharp(originalBuffer)
.resize(300, 300, { fit: "cover" })
.webp({ quality: 80 })
.toBuffer();
const id = path.parse(storageKey).name;
const thumbKey = `${id}-thumbnail.webp`;
const diskPath = path.join(this.root, thumbKey);
await mkdir(this.root, { recursive: true });
await writeFile(diskPath, thumbBuffer);
return thumbKey;
}
}Call it from MediaService.attach() for any image MIME type:
if (input.mimeType.startsWith("image/")) {
await this.thumbnailService.generate(input.buffer, key);
}Sharp uses libvips under the hood, which is roughly 4–5× faster than ImageMagick and uses a fraction of the memory. A 4000×3000 JPEG resizes to 300×300 in ~50 ms.
8. Swap to S3 in production
The local filesystem is fine for development but breaks the moment you deploy more than one instance — file uploaded to one server isn't visible from another. Add an S3-backed implementation that satisfies the same interface:
// src/storage/storage.interface.ts
export interface StorageService {
put(buffer: Buffer, fileName: string): Promise<{ key: string }>;
url(key: string): string;
}// src/storage/s3-storage.service.ts
import { Injectable } from "@nestjs/common";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { randomUUID } from "node:crypto";
import path from "node:path";
@Injectable()
export class S3StorageService implements StorageService {
private readonly client = new S3Client({ region: process.env.AWS_REGION });
private readonly bucket = process.env.S3_BUCKET!;
async put(buffer: Buffer, fileName: string) {
const key = `${randomUUID()}${path.extname(fileName)}`;
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: this.detectMime(fileName),
}),
);
return { key };
}
url(key: string) {
return `https://${this.bucket}.s3.amazonaws.com/${key}`;
}
private detectMime(name: string) {
const ext = path.extname(name).toLowerCase();
return (
{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp" }[ext] ??
"application/octet-stream"
);
}
}Bind the right implementation per environment in your module:
{
provide: "StorageService",
useClass: process.env.NODE_ENV === "production" ? S3StorageService : LocalStorageService,
}For private buckets, replace direct URLs with presigned ones using @aws-sdk/s3-request-presigner so links expire after a few minutes.
Common pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Files appear in the DB but disappear after deploy | Local disk on ephemeral container | Move to S3 (or any object store) |
| Memory blows up on big uploads | FileInterceptor buffers whole file | Use diskStorage for huge files, or stream straight to S3 |
| Thumbnails fail silently | Sharp install needs native binaries | Install Sharp on the same OS as your container; Docker builds need npm rebuild sharp |
| S3 uploads succeed but URLs are 403 | Bucket is private | Use presigned URLs or set a CloudFront distribution with OAC |
Express.Multer.File is undefined | Field name in form ≠ FileInterceptor argument | They must match exactly ("image" in both places) |
Going further: @nestbolt/medialibrary
The pipeline above is roughly 200 lines of code and covers the happy path. A real production media layer needs more: multiple collections per entity (avatars + cover + gallery), MIME and size validation per collection, multiple conversion presets (thumbnail, hero, share-card), responsive image variants, S3 + local + custom drivers from the same config block, and lifecycle events for things like CDN cache busting.
@nestbolt/medialibrary gives you all of that as a NestJS module. Decorate an entity with @HasMedia(), declare collections and conversions once, and call entity.addMedia(buffer, name).toMediaCollection("images") anywhere. Sharp conversions, polymorphic lookups, S3, and presigned URLs are all built in:
@Entity("posts")
@HasMedia()
@RegisterMediaCollections((add) => {
add("images")
.acceptsMimeTypes(["image/jpeg", "image/png", "image/webp"])
.onlyKeepLatest(10);
})
@RegisterMediaConversions((add) => {
add("thumbnail")
.resize(300, 300, { fit: "cover" })
.format("webp")
.quality(80)
.performOnCollections("images");
})
export class Post extends HasMediaMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid") id!: string;
@Column() title!: string;
}Then in your service:
await post.addMedia(buffer, "photo.jpg")
.withCustomProperties({ alt: post.title })
.toMediaCollection("images");
const thumb = await post.getFirstMediaUrl("images", "thumbnail");Read the Quick Start for the full setup, Image Conversions for the conversion API, or Storage Drivers for S3, presigned URLs, and writing your own driver.
If you found this useful, star the repo on GitHub — it's how more developers find the package.
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.