File Uploads with Next.js Server Actions
Handle file uploads in Next.js using server actions. Covers FormData, validation, cloud storage integration, progress tracking, and error handling.
What you'll learn
- ✓How to accept file uploads through server actions
- ✓Validating file type, size, and content on the server
- ✓Uploading files to S3 or similar cloud storage
- ✓Showing upload progress with useActionState
- ✓Handling multiple file uploads and error states
Prerequisites
- •Understanding of [Next.js Server Actions](/blog/nextjs-server-actions)
- •Basic knowledge of the [App Router](/blog/nextjs-app-router-basics)
Server actions accept FormData natively, which means file uploads work without building a separate API endpoint. You write a function that receives the form data, validate it, and store the file wherever you need. Next.js handles the multipart encoding and CSRF protection automatically.
Basic file upload action
Start with a server action that accepts a single file:
// app/actions/upload.ts
'use server';
import { writeFile } from 'fs/promises';
import path from 'path';
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File | null;
if (!file) {
return { error: 'No file provided' };
}
if (file.size === 0) {
return { error: 'File is empty' };
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
const filepath = path.join(uploadDir, filename);
await writeFile(filepath, buffer);
return { success: true, url: `/uploads/${filename}` };
}
The form component that calls this action:
// app/upload/page.tsx
import { uploadFile } from '@/app/actions/upload';
export default function UploadPage() {
return (
<form action={uploadFile} className="space-y-4">
<label className="block">
<span className="text-sm font-medium">Choose a file</span>
<input
type="file"
name="file"
className="mt-1 block w-full text-sm file:mr-4 file:rounded file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-blue-700"
/>
</label>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Upload
</button>
</form>
);
}
Validating files on the server
Never trust client-side validation alone. Always validate on the server:
// app/actions/upload.ts
'use server';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File | null;
if (!file || file.size === 0) {
return { error: 'Please select a file to upload.' };
}
if (!ALLOWED_TYPES.includes(file.type)) {
return {
error: `Invalid file type: ${file.type}. Allowed: JPEG, PNG, WebP, PDF.`,
};
}
if (file.size > MAX_SIZE) {
return {
error: `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is 5 MB.`,
};
}
// Additional check: read magic bytes to verify the file type
const bytes = new Uint8Array(await file.arrayBuffer());
if (!verifyMagicBytes(bytes, file.type)) {
return { error: 'File content does not match its declared type.' };
}
// Proceed with upload...
return { success: true };
}
function verifyMagicBytes(bytes: Uint8Array, type: string): boolean {
const signatures: Record<string, number[]> = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'image/webp': [0x52, 0x49, 0x46, 0x46],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
};
const expected = signatures[type];
if (!expected) return true;
return expected.every((byte, i) => bytes[i] === byte);
}
Uploading to S3
In production, store files in cloud storage instead of the local filesystem. Here is an example using the AWS SDK:
// app/actions/upload.ts
'use server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function uploadToS3(formData: FormData) {
const file = formData.get('file') as File | null;
if (!file || file.size === 0) {
return { error: 'No file provided' };
}
const buffer = Buffer.from(await file.arrayBuffer());
const ext = file.name.split('.').pop() || 'bin';
const key = `uploads/${randomUUID()}.${ext}`;
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
Body: buffer,
ContentType: file.type,
})
);
const url = `https://${process.env.S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
return { success: true, url };
}
Tracking upload state with useActionState
Use the useActionState hook to show feedback during the upload:
'use client';
import { useActionState } from 'react';
import { uploadToS3 } from '@/app/actions/upload';
const initialState = { error: '', success: false, url: '' };
export function UploadForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: typeof initialState, formData: FormData) => {
const result = await uploadToS3(formData);
return {
error: result.error || '',
success: result.success || false,
url: result.url || '',
};
},
initialState
);
return (
<form action={formAction} className="space-y-4">
<input type="file" name="file" disabled={isPending} />
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? 'Uploading...' : 'Upload'}
</button>
{state.error && (
<p className="text-red-600 text-sm">{state.error}</p>
)}
{state.success && (
<p className="text-green-600 text-sm">
Uploaded successfully:{' '}
<a href={state.url} className="underline">
{state.url}
</a>
</p>
)}
</form>
);
}
Multiple file uploads
Accept multiple files by adding the multiple attribute and iterating over them:
'use server';
export async function uploadMultiple(formData: FormData) {
const files = formData.getAll('files') as File[];
if (files.length === 0) {
return { error: 'No files selected' };
}
const results = await Promise.allSettled(
files.map(async (file) => {
if (file.size === 0) throw new Error(`${file.name} is empty`);
const buffer = Buffer.from(await file.arrayBuffer());
// Upload each file to your storage...
return { name: file.name, size: file.size };
})
);
const succeeded = results.filter((r) => r.status === 'fulfilled');
const failed = results.filter((r) => r.status === 'rejected');
return {
uploaded: succeeded.length,
failed: failed.length,
errors: failed.map((r) =>
r.status === 'rejected' ? r.reason.message : ''
),
};
}
<input type="file" name="files" multiple accept="image/*" />
Configuring the body size limit
Next.js enforces a default body size limit of 1 MB for server actions. For file uploads, you usually need to increase it:
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
};
export default config;
Set this to the maximum file size you expect to handle. For very large files (over 50 MB), consider using presigned URLs instead of streaming through the server action.
Presigned URL pattern for large files
For large files, generate a presigned URL from a server action and let the client upload directly to S3:
// app/actions/presign.ts
'use server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function getPresignedUrl(filename: string, contentType: string) {
const key = `uploads/${randomUUID()}-${filename}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
ContentType: contentType,
}),
{ expiresIn: 600 }
);
return { uploadUrl: url, key };
}
The client component then uses fetch with PUT to upload directly to S3, bypassing the server action’s body size limit entirely.
Security considerations
- Sanitize filenames before storing them. Strip path separators and special characters.
- Never serve uploaded files from your app’s public directory in production. Use a separate storage service with its own domain.
- Set Content-Disposition headers to prevent browsers from executing uploaded HTML or SVG files.
- Scan uploads for malware if you accept files from untrusted users.
- Rate-limit upload endpoints to prevent abuse.
Server actions make file uploads in Next.js straightforward. For most applications, the direct upload approach works well up to about 10 MB. Beyond that, switch to presigned URLs to keep your server lean and avoid memory pressure.
Related articles
- Next.js Next.js Server Actions: Forms and Mutations Guide
Learn how to use Next.js Server Actions for form handling, data mutations, optimistic updates, and error handling with practical examples.
- Next.js Next.js Intercepting Routes for Modals
Build modal patterns in Next.js using intercepting routes. Learn the (.) convention, combining with parallel routes, and handling hard refreshes.
- Next.js Next.js Image Optimization Strategies with next/image
Master the next/image component with advanced optimization strategies including responsive images, blur placeholders, CDN configuration, and performance tips.
- Next.js Authentication Middleware Patterns in Next.js
Implement authentication middleware in Next.js to protect routes, redirect unauthenticated users, and manage JWT or session-based auth at the edge.