Aviary
Recipes

File Upload

Upload files via FormData using the codegen fetcher — the right pattern when @UploadedFile() makes mutationOptions() impractical.

The codegen generates mutationOptions() for every non-GET route, but file uploads are a special case. NestJS uses Multer under the hood (@UploadedFile(), FileInterceptor), and the request must be sent as multipart/form-data — not JSON. The generated mutationOptions() sends JSON by default, so it won't work for file uploads.

The right approach: use the fetcher directly with a FormData body.

The controller

A standard NestJS file-upload controller using FileInterceptor:

// src/files/upload-file.controller.ts
import {
  BadRequestException,
  Body,
  Controller,
  Param,
  Post,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

interface UploadedFileResponse {
  key: string;
  name: string;
  sizeBytes: number;
}

@Controller('api/v1/files')
export class UploadFileController {
  @Post(':bucket')
  @UseInterceptors(FileInterceptor('file'))
  async upload(
    @Param('bucket') bucket: string,
    @UploadedFile() file: Express.Multer.File | undefined,
    @Body('prefix') prefix: string = '',
  ): Promise<UploadedFileResponse> {
    if (!file) throw new BadRequestException('file is required');
    // ... save to S3, local disk, etc.
    return {
      key: `${prefix}${file.originalname}`,
      name: file.originalname,
      sizeBytes: file.size,
    };
  }
}

The codegen sees this route and generates a mutationOptions() for it — but with body: never because @UploadedFile() and @Body('prefix') (single-key) aren't captured. That's expected.

The frontend

Use fetcher from the codegen output with a manually constructed FormData:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { fetcher } from '~codegen/api';
import { route } from '~codegen/routes';

function useUploadFile(bucket: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (file: File) => {
      const formData = new FormData();
      formData.append('file', file);

      const res = await fetch(route('uploadFile.upload', { bucket }), {
        method: 'POST',
        body: formData,
        // Do NOT set Content-Type — the browser sets it with the boundary
      });

      if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
      return res.json();
    },
    onSuccess: () => {
      // Invalidate any queries that list files in this bucket
      queryClient.invalidateQueries({ queryKey: ['listFiles'] });
    },
  });
}

Do not set Content-Type: multipart/form-data manually. The browser must set it itself so the boundary string is included. Setting it manually produces a malformed request.

With a prefix field

If the controller accepts additional body fields alongside the file:

mutationFn: async ({ file, prefix }: { file: File; prefix: string }) => {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('prefix', prefix);

  const res = await fetch(route('uploadFile.upload', { bucket }), {
    method: 'POST',
    body: formData,
  });

  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
  return res.json();
},

With upload progress

For large files where you want a progress indicator, use XMLHttpRequest instead of fetch:

mutationFn: ({ file, onProgress }: { file: File; onProgress?: (pct: number) => void }) =>
  new Promise((resolve, reject) => {
    const formData = new FormData();
    formData.append('file', file);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', route('uploadFile.upload', { bucket }));

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) onProgress?.(Math.round((e.loaded / e.total) * 100));
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => reject(new Error('Network error')));
    xhr.send(formData);
  }),

Why the codegen doesn't handle this

File uploads use multipart/form-data, which is fundamentally different from JSON. The codegen would need to:

  • Detect @UploadedFile(), @UploadedFiles(), FileInterceptor, FilesInterceptor, and FileFieldsInterceptor
  • Generate a File type that only exists in browser globals
  • Switch mutationFn from JSON to FormData construction

This is high complexity for a pattern that appears in 1--2 endpoints per project. The fetcher + route() approach gives you typed URLs and is only a few lines of code.

On this page