File Upload Recipe
Implement image and file uploads with automatic upload handling in your forms.
Goal
Create a product model with:
- Image thumbnail upload
- Multiple product images (gallery)
- PDF datasheet upload
- Automatic upload integration
Step 1: Create Upload Endpoint
First, implement a file upload endpoint in your backend:
// src/infrastructure/storage/upload.controller.ts
import { Controller, Post, UseInterceptors, UploadedFile, UseGuards } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@Controller('upload')
@UseGuards(JwtAuthGuard)
export class UploadController {
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: './uploads',
filename: (req, file, callback) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
callback(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
},
}),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
}),
)
async uploadFile(@UploadedFile() file: Express.Multer.File) {
return {
url: `/uploads/${file.filename}`,
filename: file.filename,
mimetype: file.mimetype,
size: file.size,
};
}
}
Configure static file serving in main.ts:
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Serve uploaded files
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
prefix: '/uploads/',
});
await app.listen(3000);
}
bootstrap();
Step 2: Define the Model with Upload Directives
// @tg_label(name)
// @tg_form()
model Product {
id String @id @default(uuid())
name String // @min(3) @max(200)
description String
/// @tg_upload(image)
thumbnail String?
/// @tg_upload(image)
images String[]
/// @tg_upload(file)
datasheet String?
price Float
stock Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Key directives:
/// @tg_upload(image)– Image upload with preview/// @tg_upload(file)– Generic file upload
Step 3: Generate Code
tgraph all
Step 4: What You Get
Backend DTOs
The DTOs remain simple strings (they receive URLs after upload):
// create-product.tg.dto.ts
export class CreateProductTgDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
description: string;
@IsString()
@IsOptional()
thumbnail?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
images?: string[];
@IsString()
@IsOptional()
datasheet?: string;
@IsNumber()
@IsNotEmpty()
price: number;
@IsInt()
@IsOptional()
stock?: number;
}
Dashboard Components
Create Form (ProductCreate.tsx):
export const ProductCreate = () => (
<Create>
<SimpleForm>
<TextInput source="name" required />
<TextInput source="description" multiline required />
{/* Single image upload with preview */}
<FileInput source="thumbnail" accept="image/*">
<ImageField source="src" title="title" />
</FileInput>
{/* Multiple image upload */}
<FileInput source="images" multiple accept="image/*">
<ImageField source="src" title="title" />
</FileInput>
{/* Generic file upload */}
<FileInput source="datasheet">
<FileField source="src" title="title" />
</FileInput>
<NumberInput source="price" required />
<NumberInput source="stock" />
</SimpleForm>
</Create>
);
Edit Form (ProductEdit.tsx):
export const ProductEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="name" required />
<TextInput source="description" multiline required />
<FileInput source="thumbnail" accept="image/*">
<ImageField source="src" title="title" />
</FileInput>
<FileInput source="images" multiple accept="image/*">
<ImageField source="src" title="title" />
</FileInput>
<FileInput source="datasheet">
<FileField source="src" title="title" />
</FileInput>
<NumberInput source="price" required />
<NumberInput source="stock" />
</SimpleForm>
</Edit>
);
Field Directives Metadata
The generator creates fieldDirectives.generated.ts:
export const fieldDirectives = {
products: {
thumbnail: { tgUpload: 'image' },
images: { tgUpload: 'image' },
datasheet: { tgUpload: 'file' },
},
};
Step 5: How Upload Works
When a user submits a form with file inputs:
- User selects file – Browser File object is added to form
- Form submission – Data provider detects File objects
- Upload phase – Each file is uploaded to
POST /upload - Response – Upload endpoint returns
{ url: '/uploads/...' } - Field replacement – File object is replaced with URL string
- API call – Model API receives clean DTO with URL strings
Data Provider Logic (automatically generated):
// Pseudo-code showing the process
const create = async (resource, { data }) => {
// Detect upload fields
const directives = fieldDirectives[resource] || {};
for (const [field, directive] of Object.entries(directives)) {
if (directive.tgUpload && data[field]) {
if (data[field] instanceof File) {
// Upload file
const formData = new FormData();
formData.append('file', data[field]);
const response = await fetch('/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
// Replace File with URL
data[field] = result.url;
}
}
}
// Now call the actual API with clean data
return httpClient.post(`/tg-api/${resource}`, data);
};
Step 6: Test the Upload
From Dashboard
- Navigate to
/products/create - Fill in the form fields
- Click “Choose file” for thumbnail
- Select an image
- See the preview appear
- Click “Save”
- The file uploads automatically and product is created
Programmatically
Upload file first:
curl -X POST http://localhost:3000/upload \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "file=@/path/to/image.jpg"
Response:
{
"url": "/uploads/file-1234567890.jpg",
"filename": "file-1234567890.jpg",
"mimetype": "image/jpeg",
"size": 102400
}
Create product with URL:
curl -X POST http://localhost:3000/tg-api/products \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"name": "Premium Widget",
"description": "High-quality widget",
"thumbnail": "/uploads/file-1234567890.jpg",
"price": 99.99,
"stock": 100
}'
Advanced Patterns
S3 Upload
Use AWS S3 for production uploads:
// upload.controller.ts
import { S3 } from 'aws-sdk';
@Controller('upload')
@UseGuards(JwtAuthGuard)
export class UploadController {
private s3: S3;
constructor() {
this.s3 = new S3({
accessKeyId: process.env.AWS_ACCESS_KEY,
secretAccessKey: process.env.AWS_SECRET_KEY,
region: process.env.AWS_REGION,
});
}
@Post()
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file: Express.Multer.File) {
const key = `uploads/${Date.now()}-${file.originalname}`;
await this.s3
.putObject({
Bucket: process.env.AWS_BUCKET,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
ACL: 'public-read',
})
.promise();
const url = `https://${process.env.AWS_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
return {
url,
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
};
}
}
Image Processing
Add image processing with Sharp:
import * as sharp from 'sharp';
@Post()
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file: Express.Multer.File) {
// Process image
const processed = await sharp(file.buffer)
.resize(800, 800, {
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({ quality: 90 })
.toBuffer();
// Generate thumbnail
const thumbnail = await sharp(file.buffer)
.resize(200, 200, { fit: 'cover' })
.jpeg({ quality: 80 })
.toBuffer();
// Save both versions
const timestamp = Date.now();
const mainPath = `uploads/${timestamp}-main.jpg`;
const thumbPath = `uploads/${timestamp}-thumb.jpg`;
await fs.promises.writeFile(mainPath, processed);
await fs.promises.writeFile(thumbPath, thumbnail);
return {
url: `/${mainPath}`,
thumbnail: `/${thumbPath}`,
mimetype: 'image/jpeg',
size: processed.length,
};
}
File Validation
Add custom validation:
@Post()
@UseInterceptors(
FileInterceptor('file', {
fileFilter: (req, file, callback) => {
// Only allow images
if (!file.mimetype.match(/^image\//)) {
return callback(new Error('Only image files are allowed'), false);
}
callback(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
}),
)
async uploadFile(@UploadedFile() file: Express.Multer.File) {
// Upload logic
}
Multiple File Types
Create specialized endpoints:
@Post('image')
@UseInterceptors(
FileInterceptor('file', {
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 },
}),
)
async uploadImage(@UploadedFile() file: Express.Multer.File) {
// Image-specific processing
}
@Post('document')
@UseInterceptors(
FileInterceptor('file', {
fileFilter: documentFilter,
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
async uploadDocument(@UploadedFile() file: Express.Multer.File) {
// Document-specific processing
}
Custom Upload Component
Create a custom upload component with drag-and-drop:
// ProductImageUpload.tsx
import { useInput } from 'react-admin';
import { useDropzone } from 'react-dropzone';
export const ProductImageUpload = ({ source }: { source: string }) => {
const { field } = useInput({ source });
const onDrop = (acceptedFiles: File[]) => {
field.onChange(acceptedFiles[0]);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': [] },
multiple: false,
});
return (
<div
{...getRootProps()}
style={{
border: '2px dashed #ccc',
padding: '20px',
textAlign: 'center',
cursor: 'pointer',
}}
>
<input {...getInputProps()} />
{isDragActive ? <p>Drop the image here...</p> : <p>Drag and drop an image, or click to select</p>}
{field.value && (
<img
src={typeof field.value === 'string' ? field.value : URL.createObjectURL(field.value)}
alt="Preview"
style={{ maxWidth: '200px', marginTop: '10px' }}
/>
)}
</div>
);
};
// Use in form
<ProductImageUpload source="thumbnail" />;
Best Practices
1. Always Validate File Types
fileFilter: (req, file, callback) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedMimes.includes(file.mimetype)) {
return callback(new Error('Invalid file type'), false);
}
callback(null, true);
};
2. Set Size Limits
limits: {
fileSize: 5 * 1024 * 1024, // 5MB for images
fileSize: 10 * 1024 * 1024, // 10MB for documents
}
3. Use Unique Filenames
filename: (req, file, callback) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
callback(null, uniqueSuffix + extname(file.originalname));
};
4. Clean Up Old Files
Implement cleanup when updating/deleting:
async update(id: string, dto: UpdateProductTgDto): Promise<Product> {
const product = await this.findOne(id);
// If thumbnail changed, delete old one
if (dto.thumbnail && product.thumbnail !== dto.thumbnail) {
await this.deleteFile(product.thumbnail);
}
return super.update(id, dto);
}
private async deleteFile(url: string): Promise<void> {
const filePath = join(__dirname, '..', '..', url);
await fs.promises.unlink(filePath).catch(() => {});
}
5. Secure Upload Endpoint
@Controller('upload')
@UseGuards(JwtAuthGuard) // Require authentication
export class UploadController {
// Only authenticated users can upload
}
Troubleshooting
Upload Returns 404
Problem: POST /upload returns 404.
Solution: Ensure UploadController is registered in a module:
@Module({
controllers: [UploadController],
})
export class StorageModule {}
File Too Large Error
Problem: Upload fails with “File too large” error.
Solution: Increase limit in FileInterceptor:
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
}
Preview Not Showing
Problem: Image preview doesn’t appear in form.
Solution: Ensure fieldDirectives.generated.ts includes your field and regenerate:
tgraph dashboard
Next Steps
- Custom Validation – Validate uploaded files
- Extending Generated Code – Add custom upload logic
- Field Directives Guide – Learn all directives