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, andFileFieldsInterceptor - Generate a
Filetype that only exists in browser globals - Switch
mutationFnfrom JSON toFormDataconstruction
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.
Auth Redirect Guard
A production-ready NestJS guard that sends 302 for plain browser requests and 409 X-Inertia-Location for Inertia XHR — the two-response pattern required by the Inertia protocol.
Using with setGlobalPrefix
How to serve Inertia pages alongside an existing API that uses NestJS setGlobalPrefix — the middleware gap and how to fix it.