Getting Started
Install nestjs-media, wire MediaModule with a disk + store, attach your first file, and serve it back — in about five minutes.
By the end of this page you'll have a working upload endpoint that stores a file on a disk, records it against an entity, and serves a thumbnail back.
Install
Pick the umbrella package plus the disks, store, and image engine you want. Everything is à la carte — install only what you use.
pnpm add @dudousxd/nestjs-media @dudousxd/nestjs-media-disk-s3 \
@dudousxd/nestjs-media-database-typeorm @dudousxd/nestjs-media-image-sharpnpm i @dudousxd/nestjs-media @dudousxd/nestjs-media-disk-s3 \
@dudousxd/nestjs-media-database-typeorm @dudousxd/nestjs-media-image-sharpyarn add @dudousxd/nestjs-media @dudousxd/nestjs-media-disk-s3 \
@dudousxd/nestjs-media-database-typeorm @dudousxd/nestjs-media-image-sharpThe umbrella package (@dudousxd/nestjs-media) carries the NestJS module and the core SPIs. Each disk, ORM store, and image engine is its own package so you never pull S3 SDKs into a local-only app, or sharp into a service that doesn't resize.
Register the module
MediaModule.forRoot wires the storage layer (disks). Pass a store and you also get the media-library layer; pass an imageProcessor and collections with conversions and you get image variants.
import { Module } from '@nestjs/common';
import { MediaModule } from '@dudousxd/nestjs-media';
import { LocalDriver } from '@dudousxd/nestjs-media-disk-local';
import { S3Driver } from '@dudousxd/nestjs-media-disk-s3';
import { TypeOrmMediaStore } from '@dudousxd/nestjs-media-database-typeorm';
import { SharpImageProcessor } from '@dudousxd/nestjs-media-image-sharp';
import { S3Client } from '@aws-sdk/client-s3';
import { getDataSourceToken } from '@nestjs/typeorm';
@Module({
imports: [
MediaModule.forRootAsync({
inject: [getDataSourceToken(), S3Client],
useFactory: (ds, s3: S3Client) => ({
default: 's3',
disks: {
s3: new S3Driver({ client: s3, bucket: 'app-uploads' }),
local: new LocalDriver({ root: './storage', baseUrl: 'http://localhost:3000/files' }),
},
store: new TypeOrmMediaStore(ds),
imageProcessor: new SharpImageProcessor(),
collections: [
{ name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
{
name: 'gallery',
conversions: [
{ name: 'thumb', width: 200 },
{ name: 'og', width: 1200, height: 630, eager: true },
],
},
],
}),
}),
],
})
export class AppModule {}MediaModule is @Global() — import it once and inject MediaService anywhere. Prefer forRoot (static config) when your disks don't need other providers; reach for forRootAsync when a disk needs an injected client or DataSource, as above.
The TypeOrmMediaStore creates the media table on first use by default (non-destructive — see Persistence). Nothing else to migrate to get started.
Accept an upload
Wire a normal NestJS controller. Here with Multer's memory storage, so the file arrives as a Buffer:
import { Controller, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { MediaService } from '@dudousxd/nestjs-media';
@Controller('posts/:id/photos')
export class PhotosController {
constructor(private readonly media: MediaService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
upload(@Param('id') postId: string, @UploadedFile() file: Express.Multer.File) {
return this.media.library.attach({
ownerType: 'Post',
ownerId: postId,
collection: 'gallery',
fileName: file.originalname,
mimeType: file.mimetype,
contents: file.buffer,
});
}
}attach writes the bytes to the collection's disk, records a MediaRecord, and (because og is eager) generates that variant right away. It returns the saved record, including its id.
Serve it back
Ask the library for a URL. Conversions are generated lazily the first time you request them, then cached:
@Injectable()
export class PhotosService {
constructor(private readonly media: MediaService) {}
async list(postId: string) {
const media = await this.media.library.list('Post', postId, 'gallery');
return Promise.all(
media.map(async (m) => ({
id: m.id,
full: await this.media.library.url(m.id),
thumb: await this.media.library.url(m.id, 'thumb'), // generated + cached on first call
})),
);
}
}And the raw storage layer is always there when you don't need an entity:
await this.media.disk('s3').put(`reports/${id}.csv`, csv, { contentType: 'text/csv' });
const link = await this.media.disk('s3').temporaryUrl(`reports/${id}.csv`, 300);Troubleshooting
`MediaLibrary is not configured`
You called media.library without passing a store to MediaModule. The two layers are independent — add a store to enable layer 2, or use media.disk(...) for raw storage.
`temporaryUrl` throws on the local disk
Signed URLs need a presign-capable driver (S3). The local disk only serves stable public URLs when you give it a baseUrl.
Next steps
- Concepts → The two layers — the model in depth
- Concepts → Uploads & multipart — resumable tus + presigned direct
- Concepts → Conversions — lazy vs eager image variants
- Recipes — avatars, galleries, direct-to-S3, raw storage
- Reference → Configuration — every
MediaModuleoption
Media
Filesystem and media-library for NestJS in one package — the Laravel/spatie feel for files. Disk-agnostic storage, resumable uploads, entity attachments, and image conversions, all wired into the ecosystem's diagnostics, codegen, and React glue points.
The two layers
Storage (layer 1) is a disk-agnostic filesystem; media-library (layer 2) attaches files to entities on top of it. How they relate, and why they're split.