Aviary

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-sharp
npm i @dudousxd/nestjs-media @dudousxd/nestjs-media-disk-s3 \
  @dudousxd/nestjs-media-database-typeorm @dudousxd/nestjs-media-image-sharp
yarn add @dudousxd/nestjs-media @dudousxd/nestjs-media-disk-s3 \
  @dudousxd/nestjs-media-database-typeorm @dudousxd/nestjs-media-image-sharp

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

app.module.ts
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:

photos.controller.ts
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:

photos.service.ts
@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

On this page