Skip to content
Codeloom
Next.js

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.

·6 min read · By Codeloom
Intermediate 12 min read

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.