Back to blog
medialibrary·8 min read

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

Khatab Wedaa

Software Engineer · Nestbolt

How to Handle File Uploads in NestJS (With Image Thumbnails and S3)

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 media table 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/multer

What each package does:

  • @nestjs/platform-express — ships with NestJS by default and includes Multer integration via FileInterceptor.
  • multer — the multipart parser. NestJS's FileInterceptor is a thin wrapper over it.
  • sharp — high-performance image processing for thumbnails and format conversion.
  • @types/multer — types for Express.Multer.File so @UploadedFile() is properly typed.

For S3 support later in the article:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

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

ProblemCauseFix
Files appear in the DB but disappear after deployLocal disk on ephemeral containerMove to S3 (or any object store)
Memory blows up on big uploadsFileInterceptor buffers whole fileUse diskStorage for huge files, or stream straight to S3
Thumbnails fail silentlySharp install needs native binariesInstall Sharp on the same OS as your container; Docker builds need npm rebuild sharp
S3 uploads succeed but URLs are 403Bucket is privateUse presigned URLs or set a CloudFront distribution with OAC
Express.Multer.File is undefinedField name in form ≠ FileInterceptor argumentThey 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.

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.