File Uploads with Server Actions: Validation and Storage
File uploads in Server Actions are straightforward: a form with an <input type="file"> sends a File object via FormData, your Server Action receives it, validates (type, size, dimensions), and stores it (local disk, cloud storage, database). Since Server Actions handle FormData natively, no additional parsing is needed. The challenge is validation: ensure files are safe, the right type, and within size limits before storing.
Server Actions are ideal for file uploads because they keep file-handling logic on the server where it's secure. You avoid exposing storage credentials to the client and can perform expensive validations (scanning for malware, checking image dimensions) without user-facing latency.
Basic File Upload Example
Start with a simple form:
// app/components/UploadAvatarForm.tsx
'use client';
import { uploadAvatar } from '@/app/actions';
import { useActionState } from 'react';
export function UploadAvatarForm() {
const [state, formAction, isPending] = useActionState(uploadAvatar, {
success: false,
error: null,
url: null,
});
return (
<form action={formAction}>
<input
type="file"
name="avatar"
accept="image/*"
required
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Uploading...' : 'Upload'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.url && (
<div>
<p>Avatar uploaded!</p>
<img src={state.url} alt="Avatar" width={100} />
</div>
)}
</form>
);
}
// app/actions.ts
'use server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
export async function uploadAvatar(prevState: any, formData: FormData) {
const file = formData.get('avatar') as File | null;
// Validate presence
if (!file) {
return { success: false, error: 'No file provided', url: null };
}
// Validate type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { success: false, error: 'Only JPEG, PNG, or WebP allowed', url: null };
}
// Validate size (max 5 MB)
const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
return { success: false, error: 'File is too large (max 5 MB)', url: null };
}
try {
// Read file as buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Save to disk
const uploadDir = join(process.cwd(), 'public', 'uploads', 'avatars');
await mkdir(uploadDir, { recursive: true });
const filename = `${Date.now()}-${file.name}`;
const filepath = join(uploadDir, filename);
await writeFile(filepath, buffer);
// Return public URL
const url = `/uploads/avatars/${filename}`;
return { success: true, error: null, url };
} catch (error) {
console.error('[uploadAvatar] Save error:', error);
return { success: false, error: 'Failed to save file', url: null };
}
}
This handles a single file, validates it, and saves it to the public directory. The public URL is returned so the form can display a preview.
Validating File Types and Dimensions
For images, validate dimensions and use a library to detect the real type (not just the extension):
// lib/file-validation.ts
import { readFile } from 'fs/promises';
import sizeOf from 'image-size';
export async function validateImage(
file: File,
options: {
maxWidth?: number;
maxHeight?: number;
maxSize?: number;
} = {}
) {
const { maxWidth = 4000, maxHeight = 4000, maxSize = 10 * 1024 * 1024 } = options;
// Check size
if (file.size > maxSize) {
return { valid: false, error: `File exceeds ${maxSize / (1024 * 1024)} MB limit` };
}
// Check mime type
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
if (!validTypes.includes(file.type)) {
return { valid: false, error: 'Invalid image type' };
}
// Check dimensions
try {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Use image-size library to detect dimensions
const dimensions = sizeOf(buffer);
if (!dimensions.width || !dimensions.height) {
return { valid: false, error: 'Could not determine image dimensions' };
}
if (dimensions.width > maxWidth || dimensions.height > maxHeight) {
return {
valid: false,
error: `Image exceeds ${maxWidth}x${maxHeight} dimensions`,
};
}
return { valid: true };
} catch (error) {
return { valid: false, error: 'Could not validate image' };
}
}
Use it in your action:
import { validateImage } from '@/lib/file-validation';
export async function uploadProductImage(prevState: any, formData: FormData) {
const file = formData.get('image') as File | null;
if (!file) {
return { success: false, error: 'No file provided', url: null };
}
// Validate with custom rules
const validation = await validateImage(file, {
maxWidth: 2000,
maxHeight: 2000,
maxSize: 8 * 1024 * 1024,
});
if (!validation.valid) {
return { success: false, error: validation.error, url: null };
}
// Proceed with upload...
}
Uploading to Cloud Storage
For production, use cloud storage (AWS S3, Google Cloud Storage, Cloudinary) instead of local disk:
// lib/s3.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function uploadToS3(key: string, body: Buffer, contentType: string) {
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
});
const result = await s3.send(command);
return `https://${process.env.AWS_S3_BUCKET}.s3.amazonaws.com/${key}`;
}
// app/actions.ts
'use server';
import { uploadToS3 } from '@/lib/s3';
export async function uploadAvatar(prevState: any, formData: FormData) {
const file = formData.get('avatar') as File | null;
if (!file) {
return { success: false, error: 'No file provided', url: null };
}
// Validate
if (!file.type.startsWith('image/')) {
return { success: false, error: 'File must be an image', url: null };
}
try {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Upload to S3
const key = `avatars/${Date.now()}-${file.name}`;
const url = await uploadToS3(key, buffer, file.type);
return { success: true, error: null, url };
} catch (error) {
console.error('[uploadAvatar]', error);
return { success: false, error: 'Upload failed', url: null };
}
}
Handling Multiple Files
For multiple files, loop through FormData entries:
// app/components/UploadGalleryForm.tsx
'use client';
import { uploadGalleryImages } from '@/app/actions';
import { useActionState } from 'react';
export function UploadGalleryForm() {
const [state, formAction, isPending] = useActionState(uploadGalleryImages, {
success: false,
error: null,
urls: [],
});
return (
<form action={formAction}>
<input
type="file"
name="images"
accept="image/*"
multiple
required
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Uploading...' : 'Upload'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.urls && state.urls.length > 0 && (
<div>
<p>Uploaded {state.urls.length} images</p>
{state.urls.map((url) => (
<img key={url} src={url} alt="Gallery" width={100} />
))}
</div>
)}
</form>
);
}
// app/actions.ts
'use server';
export async function uploadGalleryImages(prevState: any, formData: FormData) {
const files = formData.getAll('images') as File[];
if (files.length === 0) {
return { success: false, error: 'No files provided', urls: [] };
}
if (files.length > 10) {
return { success: false, error: 'Maximum 10 images allowed', urls: [] };
}
const urls: string[] = [];
for (const file of files) {
if (!file.type.startsWith('image/')) {
return { success: false, error: `${file.name} is not an image`, urls: [] };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: `${file.name} is too large`, urls: [] };
}
try {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Upload each file
const url = await uploadToS3(
`gallery/${Date.now()}-${file.name}`,
buffer,
file.type
);
urls.push(url);
} catch (error) {
console.error('[uploadGalleryImages]', error);
return { success: false, error: 'Upload failed', urls: [] };
}
}
return { success: true, error: null, urls };
}
Security Considerations
Always validate files on the server. Never trust client-side validation alone:
- Check file size and type on the server
- Scan uploaded files for malware (use ClamAV or a third-party service)
- Store files outside the web root if possible
- Rename files to prevent path traversal attacks
- Set proper MIME types and headers (Content-Disposition)
- Use rate limiting to prevent abuse
See article 9 for detailed security coverage.
Key Takeaways
- File uploads via Server Actions receive File objects from FormData; validate type, size, and dimensions before storing.
- Use cloud storage (S3, Cloudinary) in production instead of local disk for scalability and reliability.
- Validate image dimensions and detect real file types using libraries like
image-size. - Handle multiple files by looping through
formData.getAll(name). - Always validate on the server; never rely on client-side file validation alone.
Frequently Asked Questions
How do I validate a PDF file?
Check file.type === 'application/pdf' and optionally verify the file header (PDF files start with %PDF). For more robust validation, use a library like pdfjs-dist to parse the file structure.
Can I resize or optimize images during upload?
Yes, use libraries like sharp to resize and compress images: const optimized = await sharp(buffer).resize(800, 600).toBuffer(). This reduces storage costs and improves delivery speed.
How do I handle upload progress?
FormData uploads in Server Actions don't expose progress events (the browser handles the POST internally). If you need progress, use fetch() with a custom XMLHttpRequest or use a specialized upload library like tus.io.
Should I store files in the database?
For small files (avatars, profiles), you can store as base64 in a database (with size limits). For larger files, store in cloud storage and save the URL in the database. This keeps your database performant and your storage flexible.
How do I delete uploaded files?
Store the file URL (or S3 key) in your database. When deleting, retrieve the key and delete from cloud storage: await s3.deleteObject({ Bucket, Key }). For local files, use fs.unlink().