The most surprising thing about S3 uploads from Next.js Server Components is that you don’t actually upload directly from the component itself; you orchestrate it.

Let’s see this in action. Imagine a simple form in your Next.js app:

// app/upload/page.tsx
import { uploadFile } from './actions';

export default function UploadPage() {
  return (
    <form action={uploadFile}>
      <input type="file" name="myFile" required />
      <button type="submit">Upload to S3</button>
    </form>
  );
}

This form uses a Server Action uploadFile. When the form is submitted, the browser sends the file data to this Server Action. Here’s a simplified version of that action:

// app/upload/actions.ts
'use server';

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { nanoid } from 'nanoid'; // For unique filenames

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export async function uploadFile(formData: FormData) {
  const file = formData.get('myFile') as File | null;

  if (!file) {
    throw new Error('No file found');
  }

  const fileKey = `uploads/${nanoid()}-${file.name}`; // Generate a unique key

  const params = {
    Bucket: process.env.AWS_S3_BUCKET_NAME!,
    Key: fileKey,
    Body: file,
    ContentType: file.type,
  };

  try {
    const command = new PutObjectCommand(params);
    await s3Client.send(command);
    console.log(`File uploaded successfully to ${fileKey}`);
    // Redirect or return a success message
    return { success: true, url: `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${fileKey}` };
  } catch (error) {
    console.error('Error uploading file:', error);
    return { success: false, error: 'Failed to upload file' };
  }
}

This Server Action handles the heavy lifting. It receives the FormData, extracts the File object, generates a unique S3 key, and then uses the @aws-sdk/client-s3 to perform the PutObjectCommand. The S3Client is configured with your AWS credentials and region, and the PutObjectCommand specifies the bucket, the unique key for the file, the file’s content, and its MIME type.

The fundamental problem this solves is securely handling file uploads without exposing your AWS credentials in the client-side JavaScript. Server Actions execute on the server, allowing you to safely interact with AWS services. The browser sends the file data directly to your Next.js server, which then forwards it to S3. This avoids the need for a separate API endpoint just for file uploads and keeps your S3 credentials out of the browser’s reach.

The mental model is: Client -> Next.js Server (Server Action) -> S3. The FormData object is the bridge, carrying the binary file data from the browser to your server. The S3Client from the AWS SDK is the tool that performs the actual transfer to S3.

The Body parameter in PutObjectCommand can accept a ReadableStream, a Blob, a File, or a Buffer. When you get a File object from FormData, it’s directly compatible. The SDK handles streaming the data to S3 efficiently.

The ContentType is crucial for S3 to correctly serve the file later. If you omit it, S3 might default to binary/octet-stream, which isn’t ideal for web assets.

One aspect that often trips people up is how the FormData object works in Server Actions. It’s not just a simple key-value pair; it’s designed to handle multipart/form-data, which is exactly what file uploads use. The formData.get('myFile') call retrieves the File object itself, not just a string representation.

The next concept you’ll likely encounter is managing uploaded files: how to delete them, how to generate pre-signed URLs for direct client uploads (if you ever need that), or how to handle larger files with multipart uploads.

Want structured learning?

Take the full Nextjs course →