nestjs-dynamic-uploader
v0.1.0
Published
A reusable NestJS local file uploader module with dynamic upload interceptors, validation, normalized responses, and optional Sharp image processing.
Downloads
162
Maintainers
Readme
NestJS Dynamic Uploader
A reusable NestJS local file uploader module built on top of Multer. It provides dynamic upload interceptors, folder-based local storage, file validation, normalized file responses, custom validator/processor hooks, and optional Sharp-based image processing.
Package name used in this template:
nestjs-dynamic-uploader
Rename thenamefield inpackage.jsonbefore publishing if you want a scoped or project-specific package name.
Features
- Single file upload interceptor
- Multiple files upload interceptor
- Multiple named file fields upload interceptor
- Any files upload interceptor for dynamic/unknown fields
- Automatic local folder creation under
public/uploads/{folder} - Safe folder-name sanitization
- Generated collision-resistant filenames
- MIME type and extension validation
- Required-file validation
- Custom post-upload validators
- Custom post-upload processors
- Optional Sharp image resizing/compression
- Optional thumbnail generation
- Normalized file response helper
- Structured upload error responses
Installation
Install the package in a NestJS application:
npm install nestjs-dynamic-uploaderThis package expects these NestJS peer dependencies to already exist in your application:
npm install @nestjs/common @nestjs/platform-express rxjsmulter and sharp are included as runtime dependencies because the uploader stores files using Multer and the image
processor imports Sharp.
Build from source
When working on this package locally:
npm install
npm run buildThe compiled package output is generated in:
dist/To create a local npm tarball:
npm packTo install the tarball in another project:
npm install /path/to/nestjs-dynamic-uploader-0.1.0.tgzBasic setup
Import UploaderModule in the module where you want to use upload utilities.
import {Module} from '@nestjs/common';
import {UploaderModule} from 'nestjs-dynamic-uploader';
import {UsersController} from './users.controller';
@Module({
imports: [UploaderModule],
controllers: [UsersController],
})
export class UsersModule {
}Serving uploaded files
This uploader saves files under:
process.cwd()/public/uploads/{folder}For example, if folder is profile, files are stored in:
public/uploads/profileThe normalized response contains public URLs such as:
/profile/profile-20260430153022-7d9f8a2c.pngYour NestJS application must serve the public directory separately. One common setup is @nestjs/serve-static:
npm install @nestjs/serve-staticimport {Module} from '@nestjs/common';
import {ServeStaticModule} from '@nestjs/serve-static';
import {join} from 'path';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(process.cwd(), 'public', 'uploads'),
serveRoot: '/',
}),
],
})
export class AppModule {
}Single file upload
Use DynamicFileInterceptor when a route accepts one file.
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import {
DynamicFileInterceptor,
UploaderService,
} from 'nestjs-dynamic-uploader';
@Controller('users')
export class UsersController {
constructor(private readonly uploaderService: UploaderService) {
}
@Post('profile-photo')
@UseInterceptors(
DynamicFileInterceptor({
fieldName: 'profilePhoto',
folder: 'profile',
required: true,
maxFileSize: 2 * 1024 * 1024,
allowedMimeTypes: ['image/jpeg', 'image/png'],
allowedExtensions: ['jpg', 'jpeg', 'png'],
}),
)
uploadProfilePhoto(@UploadedFile() file: Express.Multer.File) {
return this.uploaderService.normalizeFile(file, 'profile');
}
}Multiple files under one field
Use DynamicFilesInterceptor when a route accepts many files under the same multipart field.
import {
Controller,
Post,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import {
DynamicFilesInterceptor,
UploaderService,
} from 'nestjs-dynamic-uploader';
@Controller('products')
export class ProductsController {
constructor(private readonly uploaderService: UploaderService) {
}
@Post('gallery')
@UseInterceptors(
DynamicFilesInterceptor({
fieldName: 'photos',
folder: 'products/gallery',
required: true,
maxCount: 5,
maxFileSize: 5 * 1024 * 1024,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp'],
}),
)
uploadGallery(@UploadedFiles() files: Express.Multer.File[]) {
return files.map((file) =>
this.uploaderService.normalizeFile(file, 'products/gallery'),
);
}
}Multiple named file fields
Use DynamicFileFieldsInterceptor when a route accepts different named file fields with different folders or validation
rules.
import {
Controller,
Post,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import {
DynamicFileFieldsInterceptor,
UploaderService,
} from 'nestjs-dynamic-uploader';
@Controller('users')
export class UsersController {
constructor(private readonly uploaderService: UploaderService) {
}
@Post('images')
@UseInterceptors(
DynamicFileFieldsInterceptor({
fields: [
{
name: 'profilePhoto',
folder: 'profile',
maxCount: 1,
required: true,
allowedMimeTypes: ['image/jpeg', 'image/png'],
allowedExtensions: ['jpg', 'jpeg', 'png'],
},
{
name: 'coverPhoto',
folder: 'cover',
maxCount: 1,
allowedMimeTypes: ['image/jpeg', 'image/png'],
allowedExtensions: ['jpg', 'jpeg', 'png'],
},
],
}),
)
uploadImages(
@UploadedFiles()
files: {
profilePhoto?: Express.Multer.File[];
coverPhoto?: Express.Multer.File[];
},
) {
return {
profilePhoto: files.profilePhoto?.map((file) =>
this.uploaderService.normalizeFile(file, 'profile'),
),
coverPhoto: files.coverPhoto?.map((file) =>
this.uploaderService.normalizeFile(file, 'cover'),
),
};
}
}fields must contain at least one field definition.
Any files upload
Use DynamicAnyFilesInterceptor only when your route must accept unknown or dynamic file field names.
import {
Controller,
Post,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import {
DynamicAnyFilesInterceptor,
UploaderService,
} from 'nestjs-dynamic-uploader';
@Controller('uploads')
export class UploadsController {
constructor(private readonly uploaderService: UploaderService) {
}
@Post('any')
@UseInterceptors(
DynamicAnyFilesInterceptor({
folder: 'misc',
maxCount: 20,
maxFileSize: 10 * 1024 * 1024,
}),
)
uploadAny(@UploadedFiles() files: Express.Multer.File[]) {
return files.map((file) => this.uploaderService.normalizeFile(file, 'misc'));
}
}For known fields, prefer DynamicFileInterceptor, DynamicFilesInterceptor, or DynamicFileFieldsInterceptor.
Upload options
Most interceptors accept these base options:
| Option | Type | Description |
|---------------------|--------------------------|--------------------------------------------------------------------------|
| folder | string | Folder inside public/uploads. Defaults to misc. |
| maxFileSize | number | Maximum file size in bytes. Defaults to 10 MB. |
| allowedMimeTypes | string[] | Allowed MIME types. If omitted, all MIME types are accepted. |
| allowedExtensions | string[] | Allowed extensions without dot. If omitted, all extensions are accepted. |
| required | boolean | Whether at least one file must be provided. |
| validators | FileValidator[] | Custom validators that run after upload. |
| processor | FilePostProcessor | Custom processor that runs after upload. |
| imageProcessing | ImageProcessingOptions | Built-in Sharp image processing options. |
Extension values should be lowercase and should not include the dot:
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'];Do not use:
allowedExtensions: ['.jpg', '.png', 'PDF'];Normalized file response
UploaderService.normalizeFile(file, folder) converts a raw Multer file into a cleaner object:
{
originalName: 'avatar.png',
filename
:
'profile-20260430153022-7d9f8a2c.png',
mimetype
:
'image/png',
extension
:
'png',
size
:
102400,
folder
:
'profile',
path
:
'/absolute/path/to/public/uploads/profile/profile-20260430153022-7d9f8a2c.png',
url
:
'/profile/profile-20260430153022-7d9f8a2c.png',
fieldName
:
'profilePhoto',
storage
:
'local',
key
:
'profile/profile-20260430153022-7d9f8a2c.png'
}Image processing
Enable built-in Sharp processing with imageProcessing.enabled.
@UseInterceptors(
DynamicFileInterceptor({
fieldName: 'photo',
folder: 'photos',
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp'],
imageProcessing: {
enabled: true,
defaultWidth: 1920,
defaultHeight: 1270,
defaultQuality: 85,
allowThumbnail: true,
defaultThumbWidth: 300,
defaultThumbHeight: 300,
defaultThumbQuality: 80,
},
}),
)Supported processed image extensions:
jpg, jpeg, png, webp, avifSVG files are skipped by the Sharp processor even if a route allows SVG uploads.
Runtime image query parameters
The processor reads optional query parameters from the request:
| Query parameter | Description | Max |
|-----------------|-------------------------------------------|--------|
| width | Main image max width | 4096 |
| height | Main image max height | 4096 |
| quality | Main image quality | 100 |
| thumbnail | true, false, 1, 0, yes, or no | — |
| thumbWidth | Thumbnail width | 1024 |
| thumbHeight | Thumbnail height | 1024 |
| thumbQuality | Thumbnail quality | 100 |
Example request:
POST /users/profile-photo?width=800&height=800&quality=80&thumbnail=trueThe main image is resized using fit: 'inside' and withoutEnlargement: true. Thumbnails are resized using
fit: 'cover'.
Thumbnail metadata
If thumbnail generation is enabled and the request uses thumbnail=true, the thumbnail is saved beside the uploaded
file with the prefix thumb_.
const fileInfo = this.uploaderService.normalizeFile(file, 'profile');
const thumbnail = this.uploaderService.getThumbnailInfo(file, 'profile');
return {
...fileInfo,
thumbnail,
};Example thumbnail response:
{
filename: 'thumb_profile-20260430153022-7d9f8a2c.png',
path
:
'/absolute/path/to/public/uploads/profile/thumb_profile-20260430153022-7d9f8a2c.png',
url
:
'/profile/thumb_profile-20260430153022-7d9f8a2c.png',
storage
:
'local',
key
:
'profile/thumb_profile-20260430153022-7d9f8a2c.png'
}If no thumbnail exists, getThumbnailInfo() returns null.
Custom validators
Custom validators run after Multer saves the file. If a validator fails, the uploaded file is deleted and a structured
422 response is thrown.
import {FileValidator} from 'nestjs-dynamic-uploader';
class MinimumFileSizeValidator implements FileValidator {
message = 'File must be at least 1 KB.';
isValid(file: Express.Multer.File): boolean {
return file.size >= 1024;
}
}Usage:
@UseInterceptors(
DynamicFileInterceptor({
fieldName: 'document',
folder: 'documents',
validators: [new MinimumFileSizeValidator()],
}),
)Custom processors
Custom processors run after validators. They can modify, replace, scan, compress, or enrich uploaded files.
import {
FilePostProcessor,
FileProcessingContext,
} from 'nestjs-dynamic-uploader';
class AuditProcessor implements FilePostProcessor {
async process(
file: Express.Multer.File,
context: FileProcessingContext,
): Promise<Express.Multer.File> {
console.log('Uploaded file:', file.filename, context.folder);
return file;
}
}Usage:
@UseInterceptors(
DynamicFileInterceptor({
fieldName: 'document',
folder: 'documents',
processor: new AuditProcessor(),
}),
)Error response format
Upload errors use a structured response shape:
{
"statusCode": 422,
"error": "unprocessable entity",
"message": "Validation failed",
"errors": [
{
"field": "profilePhoto",
"messages": [
"profilePhoto file is required."
]
}
]
}Common error scenarios include:
- Required file missing
- Invalid MIME type
- Invalid file extension
- File too large
- Too many files
- Unexpected file field
- Image processing parameter validation failure
- Custom validator failure
Folder rules
The uploader rejects unsafe folder paths to prevent path traversal.
Allowed examples:
profile
user/profile
product-images
documents/2026Rejected examples:
../private
/etc/passwd
profile/../../../secret
profile imagesFolder names can contain letters, numbers, slash, underscore, and hyphen.
Defaults
| Setting | Default |
|----------------------------------|--------------------------|
| Base public directory | public/uploads |
| Default upload folder | misc |
| Default max file size | 10 * 1024 * 1024 bytes |
| Multiple files default max count | 10 |
| Any files default max count | 20 |
| Main image default size | 1920 x 1270 |
| Main image default quality | 85 |
| Thumbnail default size | 300 x 300 |
| Thumbnail default quality | 80 |
Public API
The package exports:
export * from './uploader.module';
export * from './services/uploader.service';
export * from './decorators/dynamic-file.interceptor';
export * from './decorators/dynamic-files.interceptor';
export * from './decorators/dynamic-file-fields.interceptor';
export * from './decorators/dynamic-any-files.interceptor';
export * from './processors/sharp-image.processor';
export * from './interfaces/uploader-options.interface';
export * from './interfaces/uploaded-file-response.interface';
export * from './interfaces/file-post-processor.interface';
export * from './interfaces/file-validator.interface';Important notes
- This package currently uses local disk storage only.
- Files are saved under
process.cwd()/public/uploads. - The package generates public URLs, but your application must serve the upload directory.
sharpis a runtime dependency becauseSharpImageProcessoris imported directly by the service.- If you want
sharpto become optional, the source code should be refactored to lazy-load the image processor only when image processing is enabled.
License
MIT
