,

Amazon S3 on Vercel Marketplace: Build a Photo Gallery

Photo gallery on a laptop backed by an Amazon S3 bucket, illustrating the S3 on Vercel Marketplace tutorial

Today we are going to use Amazon S3 on Vercel Marketplace to build a small photo gallery in Next.js, with real S3 storage behind it and not a single static access key anywhere. AWS announced the integration on October 14, 2026, and the pitch is simple: provision S3, S3 Tables or S3 Vectors from the Vercel dashboard, connect it to a project, and let short-lived OIDC credentials do the authentication. We will walk through the bucket path end to end, then wire up the vector and analytics paths so you know what they look like. Budget about an hour, mostly spent reading, not typing.

A quick promise about scope. Everything below follows what the announcement describes: the variable names, the credentials helper, the commands. Where the post is silent, such as quotas or the exact list of supported Regions, we say so rather than guess. Check the linked docs for anything you plan to depend on in production.

Prerequisites

  • A Vercel account with the Vercel CLI installed (npm i -g vercel) and signed in.
  • Node.js 20 or newer and npm.
  • Basic comfort with Next.js App Router route handlers and TypeScript.
  • A decision about the AWS account: let the integration create a new account (the post mentions $100 in credits valid for six months), or link an existing one. For this tutorial, either works. Note that with a linked account, Vercel only works with new buckets it creates through the platform.
  • For the optional steps, a vector embedding of your own. S3 Vectors stores and queries vectors, but it does not create embeddings.

Build a gallery with Amazon S3 on Vercel Marketplace

The plan: create the app, provision a bucket, pull the environment, write the S3 client, add upload and list routes, add a simple page, deploy, then look at the Vectors and Tables paths and clean up. Nothing exotic, and the whole point is how little AWS plumbing you touch.

How the credential exchange works

It helps to hold a mental model before typing anything. When a Vercel Function runs, Vercel can issue it a short-lived OIDC token that identifies the project and environment. AWS Security Token Service accepts that token, checks it against a trust relationship, and hands back temporary credentials for a role. The integration sets up that role for the resource it provisioned, which is why you receive a role ARN as an environment variable instead of a key pair.

The practical effects are all good. Nothing long-lived exists to leak. Credentials expire on their own. And the permissions are bound to one resource, so a bug in your gallery cannot wander into an unrelated bucket. The cost is that your code has to run where the token can be issued, which is why local development depends on a fresh environment pull. You will see that matter in Step 4.

Step 1: Create the Next.js app

Start with a fresh project. We use the App Router and TypeScript, which matches how the Vercel and AWS examples are written.

npx create-next-app@latest s3-gallery --typescript --app --eslint --no-tailwind --src-dir --import-alias "@/*"
cd s3-gallery

Answer any remaining prompts with the defaults. When it finishes, start the dev server once with npm run dev to confirm the scaffold renders on localhost:3000, then stop it. You want to know the baseline works before cloud resources enter the picture, because debugging a broken scaffold and a broken IAM exchange at the same time is miserable.

Before moving on, glance at the layout the scaffold produced. You should have a src/app directory with a layout and page, a public folder and a next.config file. We will add a src/lib folder for the S3 client and two route directories under src/app/api. Keeping those pieces small and separate makes the troubleshooting later much easier.

Step 2: Link the project to Vercel

The integration attaches resources to a Vercel project, so the project has to exist first. The link command creates it or connects to an existing one.

vercel link

Follow the prompts to choose your scope and create a new project named s3-gallery. This writes a .vercel directory with project metadata. That directory is already ignored by the generated .gitignore, which is what you want, since it identifies your project and has no business in version control.

Step 3: Provision an S3 bucket from the Vercel dashboard

Now the part that used to take half a day. In the Vercel dashboard, open the Storage tab and choose Create. Select AWS, pick the S3 general purpose bucket type, choose a Region, and give the bucket a name. The announcement says provisioning takes a few seconds.

Pick the Region deliberately. The post says S3 is available in all commercial Regions that Vercel supports, without listing them, so choose the one nearest your function compute to keep latency down. If you accepted the new-account route, Vercel provisions the AWS account behind the scenes. If you linked an existing account, the bucket is created there under your billing.

Then connect the bucket to the s3-gallery project when prompted. The connection is what injects environment variables into the project: AWS_REGION, AWS_ROLE_ARN, AWS_RESOURCE_ARN and, for this storage type, S3_BUCKET_NAME. There is no charge for the integration itself. You pay standard S3 pricing for what you store and request.

A note on naming. Bucket names in S3 share a global namespace, so a name like gallery is almost certainly taken in the underlying system. Choose something specific to your project. The dashboard handles the naming for you, but a recognizable name helps when you find the bucket later in the AWS console or on a billing report.

Step 4: Pull the environment variables locally

Your local machine needs the same variable names as the deployed project, so pull them into a local env file.

vercel env pull .env.local

Open .env.local and confirm you see the AWS variables listed in the previous step. Do not paste these into chat or commit them. Even though the integration avoids static keys, the file carries the role ARN and a Vercel OIDC token, and the token is what proves your identity to AWS. It is short-lived, so if you come back to this project tomorrow and calls start failing with expired credentials, re-run the pull command first. That fixes most “it worked yesterday” reports.

Run a quick sanity check by listing only the variable names, not the values.

cut -d= -f1 .env.local

You should see the region, role ARN, resource ARN and bucket name variables in the output, plus whatever else Vercel adds to the file. If the bucket variable is missing, the storage resource is probably not connected to this project yet, so go back to the dashboard and connect it for the environments you need.

Step 5: Install the SDKs

We need the S3 client, the request presigner for temporary download links, and Vercel’s functions package, which contains the credentials helper.

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @vercel/functions

That is the entire dependency list for the bucket path. There is no AWS credentials file to create, no profile to configure, and no AWS_ACCESS_KEY_ID to rotate. The credentials arrive by exchange at runtime.

Version pinning is worth a thought. The AWS SDK for JavaScript v3 packages are modular and release often, and mixing wildly different versions of the client and presigner can produce confusing type errors. Installing them in one command, as above, keeps them aligned. Commit your lockfile so every teammate and every deployment builds with the same versions.

Step 6: Create the S3 client with OIDC credentials

Here is the heart of the integration. The awsCredentialsProvider function from @vercel/functions/oidc takes the role ARN, trades Vercel’s OIDC token to AWS STS, and returns temporary credentials scoped to your resource. We wrap it in one small module so every route shares the same client.

// src/lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3";
import { awsCredentialsProvider } from "@vercel/functions/oidc";

export const BUCKET = process.env.S3_BUCKET_NAME!;

export const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: awsCredentialsProvider({
    roleArn: process.env.AWS_ROLE_ARN!,
  }),
});

Keep this file on the server side only. It must never be imported from a client component, because the credentials provider relies on a server runtime. A good habit is to name server-only modules clearly and put them under lib, away from your UI code. If you want to read about the trust mechanism behind this exchange, the Vercel OIDC for AWS documentation explains it.

Photos moving along a conveyor into an S3 bucket through a gate that issues a temporary key

Step 7: Add an upload route

Next, a route handler that accepts a file from a form and writes it to the bucket with PutObjectCommand, the same command the announcement uses for general purpose storage. We prefix keys with uploads/ and a timestamp so names never collide.

// src/app/api/upload/route.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { NextResponse } from "next/server";
import { s3, BUCKET } from "@/lib/s3";

export const runtime = "nodejs";

export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return NextResponse.json({ error: "no file" }, { status: 400 });
  }
  if (!file.type.startsWith("image/")) {
    return NextResponse.json({ error: "images only" }, { status: 415 });
  }

  const key = `uploads/${Date.now()}-${file.name.replace(/[^\w.-]/g, "_")}`;
  const body = Buffer.from(await file.arrayBuffer());

  await s3.send(
    new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      Body: body,
      ContentType: file.type,
    })
  );

  return NextResponse.json({ key });
}

Notice the two guard clauses. Never trust a filename, and never trust a content type without at least checking it is the kind of thing you expect. This is a demo, so we cap nothing else, but in a real app you would also limit file size. Serverless request bodies have platform limits, so for large uploads you would switch to presigned upload URLs rather than proxying bytes through a function.

Why handle the file in a route handler at all, instead of letting the browser talk to S3 directly? Simplicity. For a small gallery, proxying through a function means you need no CORS configuration and no upload-signing flow, and the server gets to validate every file. The tradeoff is function memory and request size limits, which is why the next paragraph matters for larger files.

Step 8: Add a list route with temporary download links

The gallery needs to list objects and display them. The bucket is private, which is the correct default, so we generate a presigned URL for each object with ListObjectsV2Command and GetObjectCommand, both of which the announcement mentions.

// src/app/api/photos/route.ts
import { ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextResponse } from "next/server";
import { s3, BUCKET } from "@/lib/s3";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET() {
  const listed = await s3.send(
    new ListObjectsV2Command({ Bucket: BUCKET, Prefix: "uploads/", MaxKeys: 50 })
  );

  const photos = await Promise.all(
    (listed.Contents ?? []).map(async (obj) => ({
      key: obj.Key!,
      url: await getSignedUrl(
        s3,
        new GetObjectCommand({ Bucket: BUCKET, Key: obj.Key! }),
        { expiresIn: 300 }
      ),
    }))
  );

  return NextResponse.json({ photos });
}

A word on expiry. The links last five minutes here. Because the underlying credentials are temporary, a presigned URL cannot outlive the credentials that signed it, so short windows are a feature. If a user keeps a tab open for an hour, have the page refetch the list rather than lengthening the expiry.

Step 9: Build the page and test locally

Replace the home page with a simple client component: a file input that posts to the upload route, and a grid that loads from the photos route.

// src/app/page.tsx
"use client";
import { useEffect, useState } from "react";

type Photo = { key: string; url: string };

export default function Home() {
  const [photos, setPhotos] = useState<Photo[]>([]);
  const [busy, setBusy] = useState(false);

  async function load() {
    const res = await fetch("/api/photos");
    const data = await res.json();
    setPhotos(data.photos);
  }

  useEffect(() => { load(); }, []);

  async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setBusy(true);
    const form = new FormData();
    form.append("file", file);
    await fetch("/api/upload", { method: "POST", body: form });
    setBusy(false);
    await load();
  }

  return (
    <main style={{ padding: 24 }}>
      <h1>S3 Gallery</h1>
      <input type="file" accept="image/*" onChange={onChange} disabled={busy} />
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, 200px)", gap: 12, marginTop: 16 }}>
        {photos.map((p) => (
          <img key={p.key} src={p.url} alt={p.key} width={200} />
        ))}
      </div>
    </main>
  );
}

Run npm run dev, open localhost:3000 and upload an image. It should appear in the grid after a moment. If the upload returns a 500, read the terminal output. The two usual suspects are a stale .env.local (pull again) and a missing variable name, such as a typo in S3_BUCKET_NAME. Resist the urge to hardcode anything to make it work. If it works only with a hardcoded value, you have hidden the problem instead of solving it.

You can also confirm the object exists from the AWS side if your account is linked and you have console access: look in the bucket under the uploads/ prefix.

Two details in this component are deliberate. The upload input is disabled while a request is in flight, so people cannot double-submit. And the list reloads after every upload instead of patching local state, because the list route returns fresh presigned links each time. It is a few extra milliseconds and one less thing to get wrong.

Step 10: Deploy, then explore Vectors and Tables

Deploy to production with the CLI. The connected storage variables are already attached to the project, so nothing else needs configuring.

vercel deploy --prod

Open the deployment URL, upload another image, and confirm it appears. You now have a working S3-backed app with no static keys and no AWS console time. That is the main result.

The other two workload types use the same authentication, so the pattern carries over. For S3 Vectors, provision a vector bucket the same way. The announcement shows querying with @aws-sdk/client-s3vectors and QueryVectorsCommand, which takes a vector bucket name, an index name and a topK. You must create the index and supply your own embeddings. The snippet below shows the shape of a query route.

// src/app/api/search/route.ts
import { S3VectorsClient, QueryVectorsCommand } from "@aws-sdk/client-s3vectors";
import { awsCredentialsProvider } from "@vercel/functions/oidc";
import { NextResponse } from "next/server";

const client = new S3VectorsClient({
  region: process.env.AWS_REGION,
  credentials: awsCredentialsProvider({ roleArn: process.env.AWS_ROLE_ARN! }),
});

export async function POST(req: Request) {
  const { embedding } = await req.json();
  const res = await client.send(new QueryVectorsCommand({
    vectorBucketName: process.env.S3_VECTOR_BUCKET_NAME!,
    indexName: "photos",
    queryVector: { float32: embedding },
    topK: 5,
  }));
  return NextResponse.json({ matches: res.vectors });
}

For S3 Tables, the post runs DuckDB inside a Vercel Function with the aws, httpfs and iceberg extensions, creates an S3 secret from the temporary credentials and attaches the table bucket by ARN using the s3_tables endpoint type. The one packaging detail to remember in Next.js is to externalize the native bindings:

// next.config.mjs
const nextConfig = {
  serverExternalPackages: ["@duckdb/node-api"],
};
export default nextConfig;

The S3 Tables and S3 Vectors user guides cover table buckets, namespaces and index design, and the DuckDB documentation covers the extensions. If you would rather skip hand-coding, v0 can take a prompt such as a photo gallery, pick the matching resource and generate the connection code for you.

One more moment on the production deployment. Environment variables from the integration are scoped by environment, so if a preview deployment behaves differently from production, check that the storage resource is connected to preview as well. As a rule, give previews their own bucket rather than sharing production data with every pull request. Provisioning is fast enough that there is no excuse.

Troubleshooting

  • Expired credentials locally. Run vercel env pull .env.local again and restart the dev server so it picks up the new token.
  • Access denied on a bucket you did not provision through Vercel. The role is scoped to the resource the integration created. Use a bucket provisioned through the platform, as the announcement notes.
  • Build errors mentioning native modules when using DuckDB. Confirm @duckdb/node-api is listed in serverExternalPackages.
  • Images not loading after an hour. Presigned links expire. Refetch the list.
  • Unexpected region latency. Check that the bucket Region is close to where your functions run.

Hardening before real users arrive

A demo is not a product. Before this gallery meets the public, add authentication so that only signed-in users can upload, enforce a maximum file size, and scan or at least restrict file types more strictly than a prefix check on the content type. Consider a lifecycle rule so abandoned uploads do not accumulate forever, and turn on logging so you can trace who wrote what. None of this is hard, and all of it is easier to build now than to retrofit after something goes sideways.

Finally, decide who owns the AWS side. If the integration created a new account, make sure someone on the team can sign in to it, knows its billing contact and has set a budget alert. The convenience of one-click provisioning can hide the fact that a real account with a real bill now exists.

What you built

You built a Next.js gallery that stores images in a private S3 bucket provisioned from the Vercel dashboard, authenticates with short-lived OIDC credentials instead of access keys, uploads with PutObjectCommand, lists with ListObjectsV2Command and serves temporary links through presigned URLs. You also saw how the same pattern extends to S3 Vectors queries and DuckDB reading S3 Tables. Browse the full integration on the Vercel Marketplace AWS page.

Cleanup

Cloud resources cost money while they sit idle, so tidy up if this was a throwaway.

  • Delete the uploaded objects. Empty the uploads/ prefix from the AWS console, or remove them through a small script using DeleteObjectCommand.
  • Remove the storage resource. In the Vercel dashboard, open the Storage tab, disconnect the bucket from the project and delete the resource.
  • Delete the project with vercel remove s3-gallery if you no longer need it.
  • If you created a new AWS account through the integration, check its billing page afterwards. The $100 credit expires after six months, and an account you forget about is still an account.

ENKOMPASS IS HERE TO HELP

At Enkompass (enkompass.net), this is our home turf. we build and operate Next.js and AWS storage stacks with scoped IAM roles, lifecycle rules and logging for production workloads. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.

Leave a Reply

Ready when you are

Let’s map your route to AWS

A free 60-minute architecture review with a senior engineer. No slides, no SDR, just a plan.