Ralph LargoRL
3 min read

Uploading images directly to S3 from Next.js

A Next.js route signs a short-lived upload URL and the browser sends the image straight to S3, so the file never passes through your server. Revised from a 2024 note for AWS SDK v3 and the App Router.

  • nextjs
  • aws
  • s3
  • uploads

The file never touches your server. A Next.js route signs a short-lived upload URL, and the browser sends the image straight to S3 with it.

Add this to your Next.js app

Install the two v3 packages. The first talks to S3, and the second signs URLs:

terminal
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Then put the bucket details in .env.local. The names deliberately avoid the AWS_ prefix, because Vercel can inject its own AWS_ values into functions:

.env.local
S3_REGION=us-east-1
S3_BUCKET_NAME=largs-photo-album
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

Create a bucket on S3

If the images should be publicly viewable, untick Block all public access when you create it. New buckets block public access by default, and a public bucket policy will not save until that is off. Uploading through a presigned URL does not need public access at all; only reading the images back does.

Updating permissions

  • Select the bucket that you created.
  • Click the Permissions tab.
  • Under Bucket policy, click Edit.
  • The contents of the bucket policy are what we are going to generate with the Policy Generator.
  • Type of policy: S3 Bucket Policy
  • Effect: Allow
  • Principal: *
  • Actions: GetObject
  • Amazon Resource Name (ARN): arn:aws:s3:::largs-photo-album/*
  • After that, click Add Statement, then Generate Policy.
  • Clicking Generate Policy shows you a JSON object. Copy it and paste it in as the contents of the bucket policy.

Updating cross-origin resource sharing (CORS)

Still on the Permissions tab, scroll to Cross-origin resource sharing (CORS), click Edit, and paste this:

cors.json
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "HEAD", "GET"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": []
  }
]

"*" is fine while you are testing. Once it works, narrow AllowedOrigins to your own origins, such as http://localhost:3000 and your production domain.

Creating an IAM user

  • Open IAM, which is a separate service from S3, and go to Access management → Policies → Create policy.
  • First, select a service. Enter S3.
  • Under Actions, select PutObject.
  • Under Resources, select Specific.
  • Add the ARN for your bucket by entering the bucket name, and tick Any object name.
  • Name the policy and create it.
  • Go to Users → Create user. On the permissions step, choose Attach policies directly and pick the policy you just made.
  • Now you just have to get the access key for this user. Open the user, go to Security credentials → Create access key, choose the use case that fits (or Other), and copy both values into .env.local.

Create the upload route

app/api/upload/route.ts
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { NextResponse } from 'next/server';

const s3 = new S3Client({
  region: process.env.S3_REGION,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  },
});

export async function POST() {
  const imageName = crypto.randomUUID();

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME,
    Key: imageName,
  });

  const uploadURL = await getSignedUrl(s3, command, { expiresIn: 120 });

  return NextResponse.json({ uploadURL, imageName });
}

Each call returns a URL that accepts one upload to that key for the next 120 seconds. v3 always signs with Signature Version 4, so the old signatureVersion option is gone.

Upload from the browser

Ask the route for a URL, then send the file straight to it:

upload.ts
const { uploadURL, imageName } = await fetch('/api/upload', { method: 'POST' }).then((res) => res.json());

await fetch(uploadURL, {
  method: 'PUT',
  headers: { 'Content-Type': file.type },
  body: file,
});

Keep imageName. It is the key you use to find the image again.

Notable policies

If you want to prevent public access to your images and allow only your site to load them, use this policy in place of the public one above.

bucket-policy.json
{
  "Version": "2012-10-17",
  "Id": "http referer policy",
  "Statement": [
    {
      "Sid": "Allow get requests originating from nicelargs.vercel.app.",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::largs-photo-album/*",
      "Condition": {
        "StringLike": {
          "aws:Referer": "https://nicelargs.vercel.app/*"
        }
      }
    }
  ]
}

Comments

Sign in with GitHub to leave a note. Threads live in this repo’s discussions.