<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged aws-s3</title>
<link>https://coderlegion.com/tag/aws-s3</link>
<description>Powered by Question2Answer</description>
<item>
<title>S3 vs Supabase Storage vs Cloudinary (2026 Guide)</title>
<link>https://coderlegion.com/25886/s3-vs-supabase-storage-vs-cloudinary-2026-guide</link>
<description>&lt;p&gt;You're building a Next.js app that needs to store files — user avatars, product photos, PDFs, whatever — and you hit the same wall every developer hits: &lt;strong&gt;where do the actual files go?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For most apps, storing large user-uploaded files directly in Postgres isn't the best fit. Object storage is designed for this workload and keeps your database focused on relational data. So you're left picking between three names that show up in almost every tutorial: &lt;strong&gt;AWS S3&lt;/strong&gt;, &lt;strong&gt;Supabase Storage&lt;/strong&gt;, and &lt;strong&gt;Cloudinary&lt;/strong&gt;. They all &quot;store files,&quot; but they solve genuinely different problems, and picking the wrong one means either fighting your tools for months or paying for features you'll never touch.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll know exactly what each one is actually built for, how to wire up a basic upload with real code for all three, what they cost, and which one fits your specific project — not just whichever one showed up first in your search results.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer&quot;&gt;Quick Answer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Choose AWS S3&lt;/strong&gt; if you want inexpensive, highly flexible object storage with a huge ecosystem and full AWS control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Supabase Storage&lt;/strong&gt; if you're already using Supabase for your database and auth — it plugs directly into the same Row Level Security model you already know.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Cloudinary&lt;/strong&gt; if images and videos are a core part of your product and you want transformations (resizing, cropping, format conversion, transcoding) handled automatically, with almost no backend code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If none of those single-sentence answers settled it, keep reading — the &quot;why&quot; behind each one is where the real decision lives.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-comparison-is-tricky&quot;&gt;Why This Comparison Is Tricky&lt;/h3&gt;
&lt;p&gt;Most &quot;S3 vs X&quot; posts compare feature checklists side by side and call it a day. The problem is these three tools aren't really competing for the same job:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; is raw object storage. It stores bytes under a key. It has no idea what an &quot;image&quot; is — resizing, format conversion, and CDN delivery are all things &lt;em&gt;you&lt;/em&gt; have to bolt on separately (usually CloudFront).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; is an object-storage service with an S3-compatible API, wrapped in Supabase's API, authentication, and Postgres RLS model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; is a media pipeline first, storage second. Its actual product is on-the-fly image and video transformation — resize, crop, convert to WebP/AVIF, transcode video, remove backgrounds — all driven by URL parameters, with storage as the thing that makes that possible.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep that distinction in mind through the rest of this guide: you're not just picking &quot;where files live,&quot; you're picking how much of the surrounding work (delivery, optimization, access control) you want to own yourself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;at-a-glance&quot;&gt;At a Glance&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;AWS S3 &lt;/th&gt;&lt;th&gt;Supabase Storage &lt;/th&gt;&lt;th&gt;Cloudinary&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;What it actually is&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Raw object storage &lt;/td&gt;&lt;td&gt;Object storage + Supabase Auth/RLS integration &lt;/td&gt;&lt;td&gt;Media transformation pipeline&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Access control model&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;IAM policies + bucket policies &lt;/td&gt;&lt;td&gt;Row Level Security (SQL policies) &lt;/td&gt;&lt;td&gt;Signed uploads / upload presets&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Built-in image transforms&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;None — add an image-processing/optimization layer separately &lt;/td&gt;&lt;td&gt;Available in Paid Plans &lt;/td&gt;&lt;td&gt;Extensive (resize, crop, AI background removal, format negotiation)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Video handling&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;None — you'd wire up a separate transcoding service &lt;/td&gt;&lt;td&gt;Not a core feature &lt;/td&gt;&lt;td&gt;First-class — transcoding, adaptive streaming, a built-in video player&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;CDN included&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;No — add CloudFront separately &lt;/td&gt;&lt;td&gt;Yes, via the Supabase CDN &lt;/td&gt;&lt;td&gt;Yes, built in&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Best paired with&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Any stack, especially if you already use AWS &lt;/td&gt;&lt;td&gt;A Supabase-backed app (Postgres + Auth) &lt;/td&gt;&lt;td&gt;Any stack where images/video are core UX&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Free tier&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;12-month intro tier only, otherwise pay-as-you-go &lt;/td&gt;&lt;td&gt;~1 GB storage, 5 GB egress (check current limits) &lt;/td&gt;&lt;td&gt;25 credits/month (1 credit = 1 GB storage, 1 GB bandwidth, or 1K transformations)&amp;lt;br/&amp;gt; credits are shared across these resources&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Pricing model&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Storage + requests + data transfer, billed separately (see below) &lt;/td&gt;&lt;td&gt;Flat plan tiers + usage overages &lt;/td&gt;&lt;td&gt;Credit-based, fixed tiers&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Pricing on all three changes often enough that any number printed in a blog post is a snapshot, not a promise. Treat the figures above as ballpark, and always check each provider's live pricing page before committing budget to one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;aws-s3-raw-cheap-and-you-own-everything&quot;&gt;AWS S3: Raw, Cheap, and You Own Everything&lt;/h3&gt;
&lt;p&gt;Amazon S3 (Simple Storage Service) is the original cloud object store — files go in under a &quot;key&quot; (basically a path string), and you get them back out the same way. No opinions about what the file &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where S3 wins:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One of the cheapest raw storage per GB at real scale, especially once you're past a few hundred GB&lt;/li&gt;
&lt;li&gt;Full control — bucket policies, lifecycle rules (auto-delete or archive old files), versioning, cross-region replication&lt;/li&gt;
&lt;li&gt;The de facto standard — every CDN, backup tool, and CMS speaks S3's API, including S3-compatible alternatives like Cloudflare R2 and Backblaze B2&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where S3 struggles:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Zero built-in image processing. Want a thumbnail? You'll need a separate image-processing layer, such as a Lambda-based workflow, an image optimization service, or a framework-level optimizer.&lt;/li&gt;
&lt;li&gt;No CDN out of the box — S3 alone serves from one region. You need CloudFront (or another CDN) in front of it for real-world performance.&lt;/li&gt;
&lt;li&gt;More setup: IAM users, bucket policies, CORS config, and (for direct browser uploads) presigned URLs are all things you configure by hand.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;s3-pricing-is-more-than-per-gb-stored-&quot;&gt;S3 Pricing Is More Than &quot;Per GB Stored&quot;&lt;/h4&gt;
&lt;p&gt;This matters enough for a &quot;which is cheapest&quot; comparison that it deserves its own callout: &lt;strong&gt;S3's bill is not just storage.&lt;/strong&gt; You're billed separately for several things at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Storage&lt;/strong&gt; — per GB-month, and it varies by storage class (Standard, Infrequent Access, Glacier, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Requests&lt;/strong&gt; — &lt;code&gt;PUT&lt;/code&gt;/&lt;code&gt;COPY&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;LIST&lt;/code&gt; requests cost more per-request than &lt;code&gt;GET&lt;/code&gt; requests, and both are metered&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data transfer out&lt;/strong&gt; — serving files to the internet (not through a CDN) is billed per GB and is usually the line item that surprises people&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retrieval fees&lt;/strong&gt; — if you move infrequently-accessed files to a cheaper storage class, pulling them back out costs extra&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lifecycle transitions&lt;/strong&gt; — moving objects between storage classes automatically (e.g. Standard → Glacier after 90 days) also incurs a small per-object request cost&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A storage-only comparison (S3 vs. Supabase's per-GB rate vs. Cloudinary's credits) misses this. An app that serves thumbnails directly from S3 with heavy traffic and no CDN in front can rack up request and transfer charges that dwarf the storage bill itself — which is exactly why pairing S3 with CloudFront isn't just a performance decision, it's often a cost one too (CloudFront's cached data transfer is typically cheaper than S3's direct transfer-out rate).&lt;/p&gt;
&lt;h4 id=&quot;a-minimal-s3-upload-in-next-js-16&quot;&gt;A Minimal S3 Upload in Next.js 16&lt;/h4&gt;
&lt;p&gt;This uses the &lt;strong&gt;presigned URL pattern&lt;/strong&gt; — your server never touches the file bytes, it just asks S3 for permission and hands the browser a temporary signed URL to upload directly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/s3-client.ts
import { S3Client } from &quot;@aws-sdk/client-s3&quot;;

export const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/presigned-url/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { PutObjectCommand } from &quot;@aws-sdk/client-s3&quot;;
import { getSignedUrl } from &quot;@aws-sdk/s3-request-presigner&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;
import { auth } from &quot;@/lib/auth&quot;; // your auth solution of choice

const ALLOWED_TYPES = [&quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;image/webp&quot;];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

export async function POST(request: NextRequest) {
  // 1. Authentication — reject anonymous requests before doing anything else
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const { fileName, fileType, fileSize } = await request.json();

  // 2. MIME allowlist — reject anything you don't explicitly support
  if (!ALLOWED_TYPES.includes(fileType)) {
    return NextResponse.json({ error: &quot;Unsupported file type&quot; }, { status: 400 });
  }

  // 3. Max file size — a best-effort check against the size the client reports.
  // A presigned PUT can't fully enforce this on its own; see the note below.
  if (typeof fileSize !== &quot;number&quot; || fileSize &amp;gt; MAX_FILE_SIZE) {
    return NextResponse.json({ error: &quot;File too large&quot; }, { status: 400 });
  }

  // 4. Filename sanitization — never trust the client's filename for anything
  // beyond picking a safe extension. Strip everything else.
  const safeExtension =
    fileName?.split(&quot;.&quot;).pop()?.replace(/[^a-z0-9]/gi, &quot;&quot;).slice(0, 10) || &quot;bin&quot;;

  // 5. Server-generated key — the client never chooses where its file lands.
  // Scoping by the authenticated user's id is also your authorization boundary:
  // even if someone tampers with the request, they can only ever get a
  // presigned URL scoped to their own prefix.
  const key = `uploads/${session.user.id}/${randomUUID()}.${safeExtension}`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: key,
    ContentType: fileType,
  });

  const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 60 });

  return NextResponse.json({ uploadUrl, key });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; your server never sees the file bytes — it just signs a temporary permission slip — but it &lt;em&gt;does&lt;/em&gt; gate who gets that slip (authentication), what they're allowed to upload (MIME allowlist), roughly how big it can be, and exactly where it lands (a server-generated key under the caller's own user id, never a client-supplied path). The browser then does a plain &lt;code&gt;fetch(uploadUrl, { method: &quot;PUT&quot;, body: file })&lt;/code&gt; straight to S3, keeping large files off your server entirely — which matters a lot on serverless platforms with body-size limits.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;This example is still a demonstration, not a drop-in production upload endpoint.&lt;/strong&gt; A few things it deliberately leaves out, worth knowing about before you ship:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Authoritative size enforcement&lt;/strong&gt; — the &lt;code&gt;fileSize&lt;/code&gt; check above only validates what the client &lt;em&gt;claims&lt;/em&gt; its file is. A presigned &lt;code&gt;PutObjectCommand&lt;/code&gt; doesn't enforce a byte limit by itself. For a hard limit, use a &lt;strong&gt;presigned POST&lt;/strong&gt; with a &lt;code&gt;content-length-range&lt;/code&gt; condition instead of a presigned PUT, or verify the object's real size after upload (via an S3 event notification or a scheduled cleanup job) and delete anything oversized.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Checksum/content validation&lt;/strong&gt; — for anything security-sensitive, pass a &lt;code&gt;ChecksumSHA256&lt;/code&gt; to &lt;code&gt;PutObjectCommand&lt;/code&gt; so S3 rejects an upload whose bytes don't match what the client claimed, and consider scanning uploaded files (e.g. with a virus-scanning Lambda trigger) before treating them as safe to serve.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; — nothing above stops one authenticated user from requesting thousands of presigned URLs per minute. Put a rate limiter (Upstash Ratelimit, a simple sliding-window check in your database, or your edge/CDN's built-in limiting) in front of this route in production.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Making the bucket public &quot;to make things easier.&quot; None of the common upload/display patterns need a public bucket — presigned URLs grant scoped, temporary access without exposing the bucket itself. Leave &lt;strong&gt;Block all public access&lt;/strong&gt; on unless you have a specific, narrow reason to open one prefix.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;supabase-storage-s3-underneath-postgres-on-top&quot;&gt;Supabase Storage: S3 Underneath, Postgres on Top&lt;/h3&gt;
&lt;p&gt;Supabase Storage provides an S3-compatible object-storage interface and integrates it with &lt;strong&gt;Supabase Auth&lt;/strong&gt; and &lt;strong&gt;Postgres RLS&lt;/strong&gt; — every bucket can be protected by &lt;strong&gt;Row Level Security policies&lt;/strong&gt;, the same SQL-based authorization system you're already using for your database tables if you're on Supabase.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where Supabase Storage wins:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you're already using Supabase Auth + Postgres, storage access rules live in the &lt;em&gt;same&lt;/em&gt; mental model — a policy like &quot;a user can only read their own files&quot; is one &lt;code&gt;create policy&lt;/code&gt; statement, not a separate IAM configuration&lt;/li&gt;
&lt;li&gt;Simple, batteries-included API (&lt;code&gt;upload&lt;/code&gt;, &lt;code&gt;download&lt;/code&gt;, &lt;code&gt;createSignedUrl&lt;/code&gt;) that works identically from the browser or the server&lt;/li&gt;
&lt;li&gt;Offers on-the-fly image transformations included via a query-param API, so you don't always need a separate CDN layer for simple resizing, although availability and usage depend on your plan&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where Supabase Storage struggles:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Currently supports automatic WebP optimization but its transformation system is considerably narrower than Cloudinary's&lt;/li&gt;
&lt;li&gt;Free-tier storage and egress are modest — fine for an MVP, tight for a media-heavy app&lt;/li&gt;
&lt;li&gt;You're tied to Supabase's infrastructure choices; less low-level control than raw S3&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;a-minimal-supabase-storage-upload&quot;&gt;A Minimal Supabase Storage Upload&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @supabase/supabase-js @supabase/ssr
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/client.ts
import { createBrowserClient } from &quot;@supabase/ssr&quot;;

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/avatar-uploader.tsx
&quot;use client&quot;;

import { createClient } from &quot;@/lib/supabase/client&quot;;

export function AvatarUploader() {
  const supabase = createClient();

  const handleUpload = async (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const file = e.target.files?.[0];
    if (!file) return;

    // Get the current user from the session — never take a userId as a
    // prop or form value. The path below is only as trustworthy as this.
    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) return;

    const filePath = `${user.id}/${Date.now()}-${file.name}`;

    const { error } = await supabase.storage
      .from(&quot;avatars&quot;)
      .upload(filePath, file, { upsert: false });

    if (error) {
      console.error(&quot;Upload failed:&quot;, error.message);
      return;
    }

    // For a private bucket, generate a temporary signed URL to display it
    const { data: signed } = await supabase.storage
      .from(&quot;avatars&quot;)
      .createSignedUrl(filePath, 60 * 60); // 1 hour

    console.log(&quot;Signed URL:&quot;, signed?.signedUrl);
  };

  return &amp;lt;input type=&quot;file&quot; accept=&quot;image/*&quot; onChange={handleUpload} /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;.storage.from(&quot;avatars&quot;).upload(...)&lt;/code&gt; uploads directly through the Supabase client — no separate presigned-URL round trip needed, because auth and authorization already ride along with the client's session. &lt;code&gt;createSignedUrl&lt;/code&gt; mirrors S3's presigned-URL pattern, but scoped through the bucket's own RLS policies instead of IAM.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Only let a user manage files inside their own folder
create policy &quot;Users can upload their own avatar&quot;
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;storage.foldername(name)&lt;/code&gt; splits the object path (e.g. &lt;code&gt;user-id/photo.jpg&lt;/code&gt;) into segments, so this policy checks that the folder name matches the uploader's own &lt;code&gt;auth.uid()&lt;/code&gt;. The authorization model uses Postgres RLS, just like your database tables.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Important — don't mistake a client-supplied path for real security.&lt;/strong&gt; In the component above, &lt;code&gt;user.id&lt;/code&gt; comes from &lt;code&gt;supabase.auth.getUser()&lt;/code&gt;, which reads the &lt;em&gt;server-verified&lt;/em&gt; session — that part is trustworthy. But the resulting &lt;code&gt;filePath&lt;/code&gt; string is still just something the client constructs and sends along with the upload request. What actually makes this safe is the &lt;strong&gt;RLS policy&lt;/strong&gt;, which independently re-checks &lt;code&gt;(select auth.uid())&lt;/code&gt; against the path on Supabase's side, every time, regardless of what the client sends.&lt;/p&gt;
&lt;p&gt;This distinction matters: if you ever accept a user id as a raw prop, a hidden form field, or anything else that isn't derived from the verified session — or if RLS on the bucket is ever disabled or misconfigured — nothing stops a client from writing &lt;code&gt;someone-elses-id/file.jpg&lt;/code&gt; into the path and having the request succeed. The policy is the actual security boundary here, not the fact that the code &quot;usually&quot; builds the path from the right value.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Writing RLS policies by hand for every bucket pattern (owner-only, team-shared, admin override) gets repetitive fast. If you're already on Supabase, a &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; can produce the strict, performance-optimized version of these policies — including the &lt;code&gt;(select auth.uid())&lt;/code&gt; wrapper used above — for your specific access pattern.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cloudinary-a-media-pipeline-for-images-and-video&quot;&gt;Cloudinary: A Media Pipeline for Images &lt;em&gt;and&lt;/em&gt; Video&lt;/h3&gt;
&lt;p&gt;Cloudinary's whole pitch is different from the other two: you're not really buying storage, you're buying &lt;strong&gt;transformation-on-delivery&lt;/strong&gt; — and it's easy to think of Cloudinary as &quot;an image tool,&quot; but that undersells it. Video is a first-class part of the product too: transcoding, adaptive bitrate streaming, and a dedicated video player component (&lt;code&gt;CldVideoPlayer&lt;/code&gt;) ship alongside the image tooling, not as an afterthought. Upload one asset once, and every size, crop, and format you'll ever need — image or video — gets generated from URL parameters, with no separate resize job and no manual WebP conversion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where Cloudinary wins:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Transformations are wildly capable across both media types: resize, crop, auto-format (automatically selects an appropriate optimized format for the requesting browser), AI background removal, text/image overlays for images; transcoding and adaptive streaming for video&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;next-cloudinary&lt;/code&gt; SDK gives you drop-in components — &lt;code&gt;&amp;lt;CldImage&amp;gt;&lt;/code&gt; wraps &lt;code&gt;next/image&lt;/code&gt;, &lt;code&gt;&amp;lt;CldVideoPlayer&amp;gt;&lt;/code&gt; handles video — with almost no configuration&lt;/li&gt;
&lt;li&gt;Built-in CDN delivery for both, so there's no separate CloudFront-equivalent to wire up&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where Cloudinary struggles:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Free tier (25 credits/month, where 1 credit ≈ 1 GB storage, 1 GB bandwidth, &lt;em&gt;or&lt;/em&gt; 1,000 transformations) disappears fast on anything with real traffic — video in particular eats through credits quickly since files are larger&lt;/li&gt;
&lt;li&gt;Pricing is credit-based across three different resource types at once, which makes forecasting cost harder than a flat per-GB rate&lt;/li&gt;
&lt;li&gt;It's optimized for media-heavy applications; if most of your workload is arbitrary documents and backups, general-purpose object storage is usually a more natural fit&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;a-minimal-cloudinary-upload-display-in-next-js-16&quot;&gt;A Minimal Cloudinary Upload + Display in Next.js 16&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install next-cloudinary cloudinary
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/sign-cloudinary-params/route.ts
import { v2 as cloudinary } from &quot;cloudinary&quot;;
import { NextResponse } from &quot;next/server&quot;;
import { auth } from &quot;@/lib/auth&quot;; // your auth solution of choice

cloudinary.config({
  cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

// The widget generates its own paramsToSign and sends them here — never
// sign that object blindly. Only a small, known set of keys is allowed
// through; anything else gets the whole request rejected.
const ALLOWED_PARAM_KEYS = new Set([&quot;timestamp&quot;, &quot;folder&quot;, &quot;upload_preset&quot;, &quot;source&quot;]);

export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const { paramsToSign } = await request.json();
  const keys = Object.keys(paramsToSign ?? {});
  const unexpectedKeys = keys.filter((key) =&amp;gt; !ALLOWED_PARAM_KEYS.has(key));

  if (unexpectedKeys.length &amp;gt; 0) {
    return NextResponse.json(
      { error: `Unexpected upload parameters: ${unexpectedKeys.join(&quot;, &quot;)}` },
      { status: 400 }
    );
  }

  const signature = cloudinary.utils.api_sign_request(
    paramsToSign,
    process.env.CLOUDINARY_API_SECRET!
  );

  return NextResponse.json({ signature });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; the widget builds &lt;code&gt;paramsToSign&lt;/code&gt; on its own based on how it's configured, and sends that exact object to your endpoint to be signed — so you can't simply substitute your own parameters the way you might for a normal API request, or the signature won't match what the widget actually uploads with. What you &lt;em&gt;can&lt;/em&gt; do, and what this endpoint does, is refuse to sign anything that isn't on a small allowlist of expected keys, and require a logged-in session before generating a signature at all. This stops the endpoint from being used to sign arbitrary transformation instructions, moderation overrides, or unexpected metadata that a modified client request might try to sneak in. Once signed, the upload still goes directly from the browser to Cloudinary — your &lt;code&gt;CLOUDINARY_API_SECRET&lt;/code&gt; never reaches the client.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Signed uploads narrow &lt;em&gt;who&lt;/em&gt; can request a signature, but the file itself — its folder, allowed formats, and max size — is still best constrained by an &lt;strong&gt;upload preset&lt;/strong&gt; configured in the Cloudinary dashboard (Settings → Upload → Upload presets), since a preset's restrictions are enforced by Cloudinary itself and can't be bypassed by tampering with the request the way a loosely-validated &lt;code&gt;paramsToSign&lt;/code&gt; object could be.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/cloudinary-uploader.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { CldUploadWidget, CldImage } from &quot;next-cloudinary&quot;;

export function CloudinaryUploader() {
  const [publicId, setPublicId] = useState&amp;lt;string | null&amp;gt;(null);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;CldUploadWidget
        signatureEndpoint=&quot;/api/sign-cloudinary-params&quot;
        options={{ folder: &quot;uploads&quot; }}
        onSuccess={(result) =&amp;gt; {
          if (result.info &amp;amp;&amp;amp; typeof result.info === &quot;object&quot;) {
            setPublicId(result.info.public_id);
          }
        }}
      &amp;gt;
        {({ open }) =&amp;gt; (
          &amp;lt;button onClick={() =&amp;gt; open()}&amp;gt;Upload an image&amp;lt;/button&amp;gt;
        )}
      &amp;lt;/CldUploadWidget&amp;gt;

      {publicId &amp;amp;&amp;amp; (
        &amp;lt;CldImage
          src={publicId}
          width={600}
          height={400}
          crop=&quot;fill&quot;
          alt=&quot;Uploaded image&quot;
        /&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;&amp;lt;CldImage&amp;gt;&lt;/code&gt; renders the uploaded asset with a &lt;code&gt;crop=&quot;fill&quot;&lt;/code&gt; transformation applied automatically, on top of &lt;code&gt;next/image&lt;/code&gt;'s own optimization. Swap this component for &lt;code&gt;&amp;lt;CldVideoPlayer&amp;gt;&lt;/code&gt; and the same signed-upload flow works for video uploads, using the same signing endpoint.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Using an &lt;strong&gt;unsigned&lt;/strong&gt; upload preset (no &lt;code&gt;signatureEndpoint&lt;/code&gt;, just a public preset name) for anything beyond a quick prototype. Anyone who knows the preset name can initiate uploads subject to the restrictions configured on that preset — fine for a personal demo, risky for anything public-facing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;media-optimization-who-actually-handles-it-&quot;&gt;Media Optimization: Who Actually Handles It?&lt;/h3&gt;
&lt;p&gt;This is usually the deciding factor in practice, so it's worth calling out directly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; does nothing for you, for images or video. You either accept unoptimized originals, or build your own pipeline (Lambda@Edge, a CloudFront Function, a dedicated transcoding service, or routing S3 through Cloudinary/imgix as a second layer).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; offers a transformation API (resize + quality via query params) that works with a &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/docs/guides/storage/serving/image-transformations#nextjs-loader&quot;&gt;custom Next.js image loader&lt;/a&gt; — available only in Paid Plans, and images only; there's no equivalent video pipeline.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; is purpose-built for exactly this, for both media types. Automatic format negotiation (AVIF/WebP per browser), on-the-fly cropping, &lt;code&gt;&amp;lt;CldImage&amp;gt;&lt;/code&gt;'s tight &lt;code&gt;next/image&lt;/code&gt; integration, and adaptive-bitrate video streaming mean you write the least code here.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your app's core UX depends on fast, well-optimized media at scale — a marketplace, a photo-heavy blog, a portfolio builder, or a product with video uploads (course platforms, social apps, UGC video) — that alone can justify Cloudinary's cost. If media is incidental — a profile picture here, a document there — S3 or Supabase Storage is plenty.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;security-models-compared&quot;&gt;Security Models Compared&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;How access is controlled&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;S3&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;IAM policies (who can call the API) + bucket policies (what's publicly readable) + presigned URLs for temporary, scoped access&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Row Level Security policies written in SQL, evaluated per-request against the logged-in user — the same system protecting your database tables&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Signed uploads (server signs the request, ideally after validating it) or unsigned upload presets (public, rate-limited by preset config)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The practical difference: S3 and Cloudinary both support &lt;strong&gt;signed requests for scoped upload/access workflows&lt;/strong&gt;, while Supabase emphasizes &lt;strong&gt;identity-aware RLS policies&lt;/strong&gt;. Supabase Storage centers on &lt;strong&gt;who is asking&lt;/strong&gt;, evaluated through Postgres RLS policies. If your app already has rich, row-level permission logic in Postgres, Supabase Storage lets you reuse that thinking instead of maintaining a parallel IAM policy language — but in all three, a client-constructed path or parameter is never the actual security boundary on its own; the server-side check (RLS, IAM, or a whitelist) is.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;can-you-mix-them-&quot;&gt;Can You Mix Them?&lt;/h3&gt;
&lt;p&gt;Yes, and plenty of real apps do. A common pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; (or Cloudflare R2, an S3-compatible alternative) for cheap, high-volume storage of originals — documents, raw uploads, backups&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; in front of just the &lt;em&gt;media&lt;/em&gt; subset (images and video), either by uploading directly to Cloudinary or using its &lt;a rel=&quot;nofollow&quot; href=&quot;https://cloudinary.com/documentation/fetch_remote_images&quot;&gt;remote fetch&lt;/a&gt; feature to transform images that already live in S3&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; for anything tightly coupled to your Supabase-authenticated user data, where RLS access rules matter more than transformation power&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There's no rule that says &quot;pick exactly one.&quot; Reach for the tool that fits &lt;em&gt;that specific type of file&lt;/em&gt;, not a single storage layer for your entire app by default.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;decision-matrix&quot;&gt;Decision Matrix&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Your situation &lt;/th&gt;&lt;th&gt;Pick&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Already deep in AWS infrastructure &lt;/td&gt;&lt;td&gt;S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Need the cheapest storage at real scale, and can account for request/transfer costs &lt;/td&gt;&lt;td&gt;S3 / R2 depending on egress profile&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Already using Supabase for auth + database &lt;/td&gt;&lt;td&gt;Supabase Storage&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Want row-level access rules without a separate IAM setup &lt;/td&gt;&lt;td&gt;Supabase Storage&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Images/video are core to your product's UX &lt;/td&gt;&lt;td&gt;Cloudinary&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Want automatic format optimization with near-zero setup &lt;/td&gt;&lt;td&gt;Cloudinary&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Storing mostly non-image files (PDFs, CSVs, backups) &lt;/td&gt;&lt;td&gt;S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Small MVP, want the fastest path to &quot;it works&quot; &lt;/td&gt;&lt;td&gt;Supabase Storage if the app already uses Supabase;&amp;lt;br/&amp;gt;Cloudinary if images or video are central.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;is-supabase-storage-actually-built-on-s3-&quot;&gt;Is Supabase Storage actually built on S3?&lt;/h4&gt;
&lt;p&gt;Supabase Storage is S3-compatible and exposes an S3 protocol endpoint, so tools that speak the S3 API can interact with it. The important distinction is that Supabase adds its own API, authentication integration, and Postgres RLS model.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-cloudinary-if-i-m-already-using-next-image-&quot;&gt;Do I need Cloudinary if I'm already using next/image?&lt;/h4&gt;
&lt;p&gt;Not necessarily. &lt;code&gt;next/image&lt;/code&gt; already handles resizing, format conversion, and lazy loading for images you serve from S3 or Supabase Storage — it just needs the source domain allowlisted in &lt;code&gt;remotePatterns&lt;/code&gt;. Reach for Cloudinary specifically when you need transformations &lt;code&gt;next/image&lt;/code&gt; doesn't do itself, like AI background removal, video transcoding, or on-the-fly cropping driven by user input.&lt;/p&gt;
&lt;h4 id=&quot;which-one-is-cheapest-for-a-small-side-project-&quot;&gt;Which one is cheapest for a small side project?&lt;/h4&gt;
&lt;p&gt;For a small MVP, Supabase Storage is attractive if you're already using Supabase, while Cloudinary is attractive when image or video transformations are central. S3 can also be inexpensive, but remember it's billed across storage, requests, and data transfer separately — factor in all three before assuming it's the cheapest option for your specific traffic pattern.&lt;/p&gt;
&lt;h4 id=&quot;is-a-presigned-s3-upload-url-alone-enough-security-&quot;&gt;Is a presigned S3 upload URL alone enough security?&lt;/h4&gt;
&lt;p&gt;No. A presigned URL controls &lt;em&gt;whether&lt;/em&gt; an upload to a specific key is allowed within its expiry window — it doesn't validate file type, size, or content on its own beyond what you sign into the request. Pair it with server-side checks (auth, MIME allowlist, size limits) before issuing the URL, as shown in this guide's example, and treat anything beyond that (checksums, virus scanning, rate limiting) as genuinely needed for production, not optional polish.&lt;/p&gt;
&lt;h4 id=&quot;can-i-switch-between-these-later-without-a-full-rewrite-&quot;&gt;Can I switch between these later without a full rewrite?&lt;/h4&gt;
&lt;p&gt;Keeping storage access behind a small abstraction makes migration easier. The files themselves may be easy to migrate; the harder part is usually recreating URLs, access-control rules, transformations, and metadata.&lt;/p&gt;
&lt;h4 id=&quot;does-cloudinary-replace-the-need-for-a-cdn-&quot;&gt;Does Cloudinary replace the need for a CDN?&lt;/h4&gt;
&lt;p&gt;Yes — Cloudinary delivers every asset through its own CDN by default, so you don't need to add CloudFront or another CDN in front of it the way you would with raw S3.&lt;/p&gt;
&lt;h4 id=&quot;what-about-cloudflare-r2-as-another-alternative-&quot;&gt;What about Cloudflare R2 as another alternative?&lt;/h4&gt;
&lt;p&gt;R2's S3-compatible API makes many application patterns—such as presigned URLs and S3 SDK usage—very similar to S3, although its pricing and access-control model differ.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;None of these three is a universally &quot;better&quot; choice — they're solving different halves of the same problem. S3 gives you the cheapest, most flexible raw storage and expects you to build the rest, including the parts of its own billing model (requests, transfer, retrieval) that are easy to overlook. Supabase Storage trades some of that flexibility for tight integration with the auth and database system you're probably already using — just remember that a client-supplied path is never the actual security boundary; the RLS policy is. Cloudinary trades storage cost for a transformation pipeline — for images &lt;em&gt;and&lt;/em&gt; video — that would otherwise take real engineering time to build yourself.&lt;/p&gt;
&lt;p&gt;Start by asking what you're actually storing and who needs to see it — that answer usually points to one of these three faster than a feature-by-feature comparison ever will.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16&quot;&gt;Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy &amp;amp; Multipart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;How to Display Images from S3 in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/image-optimization-nextjs-16&quot;&gt;Image Optimization in Next.js 16: The Complete 2026 Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth: Which One Should You Choose in 2026?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — generate strict, pattern-specific Row Level Security policies for your storage buckets, including the owner-only pattern shown in this guide.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25886/s3-vs-supabase-storage-vs-cloudinary-2026-guide</guid>
<pubDate>Mon, 31 Aug 2026 03:19:01 +0000</pubDate>
</item>
<item>
<title>How to Display Images from S3 in Next.js 16 (2026 Guide)</title>
<link>https://coderlegion.com/23980/how-to-display-images-from-s3-in-next-js-16-2026-guide</link>
<description>&lt;p&gt;Getting a file into S3 is only half the job. Sooner or later you need to actually show that image back to the user — on a profile page, in a gallery, inside a product card — and that's where a second wave of questions shows up. Do you just paste the S3 URL into an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag? Does &lt;code&gt;next/image&lt;/code&gt; even work with S3? What if the file is private and shouldn't be reachable by just anyone with the link?&lt;/p&gt;
&lt;p&gt;If you followed &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16&quot;&gt;our guide on uploading files to S3 with Next.js 16&lt;/a&gt;, you already have files living in one of three ways — proxied through your server, uploaded directly via a presigned URL, or pushed up in multipart chunks. The good news: &lt;strong&gt;how you display an image doesn't depend on how it was uploaded.&lt;/strong&gt; It depends on one thing only — is the object public or private?&lt;/p&gt;
&lt;p&gt;In this guide, you'll learn all three real-world patterns for displaying S3 images in Next.js 16:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Public bucket URLs&lt;/strong&gt; — the simplest option, for non-sensitive assets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Presigned GET URLs&lt;/strong&gt; — for private files that only the right user should see&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CloudFront CDN delivery&lt;/strong&gt; — for performance and scale, with both public and signed variants&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By the end, you'll know exactly which pattern fits your use case and have working code for each.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;It's tempting to think &quot;S3 has a URL, I'll just use that.&quot; Sometimes that's genuinely fine. But most real apps hit one of these problems fast:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Not everything should be public.&lt;/strong&gt; A user's private document or someone else's uploaded receipt shouldn't be reachable by anyone who guesses or copies the URL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;next/image&lt;/code&gt; doesn't know about S3 by default.&lt;/strong&gt; Next.js's image optimizer blocks external domains until you explicitly allow them — skip this step and your images silently fail to load.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Raw S3 isn't a CDN.&lt;/strong&gt; Every request hits the bucket directly, in one region, with no edge caching — fine for a side project, not great once you have real traffic.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the real question for every image you display is: &lt;strong&gt;&quot;Who is allowed to see this, and does it need to be fast at scale?&quot;&lt;/strong&gt; That answer picks your pattern.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you're unsure whether something should be public, default to private. It's much easier to make a private file public later than to discover a &quot;public&quot; file leaked something sensitive.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites-amp-setup&quot;&gt;Prerequisites &amp;amp; Setup&lt;/h3&gt;
&lt;p&gt;This guide continues directly from &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16&quot;&gt;our S3 upload guide&lt;/a&gt; and assumes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the UI examples&lt;/li&gt;
&lt;li&gt;The same S3 bucket and &lt;code&gt;s3Client&lt;/code&gt; from the upload guide's &lt;code&gt;lib/s3-client.ts&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you haven't set up the S3 client yet, &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16#prerequisites-setup&quot;&gt;go set that up first&lt;/a&gt; — every pattern below reuses it.&lt;/p&gt;
&lt;h4 id=&quot;allow-s3-and-cloudfront-in-next-config&quot;&gt;Allow S3 (and CloudFront) in next.config&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; refuses to optimize images from a domain it doesn't recognize — this is a security default, not a bug. Add your bucket's hostname to &lt;code&gt;remotePatterns&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
import type { NextConfig } from &quot;next&quot;;

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: &quot;https&quot;,
        hostname: &quot;devstacked-uploads-demo.s3.us-east-1.amazonaws.com&quot;,
      },
      {
        protocol: &quot;https&quot;,
        hostname: &quot;*.cloudfront.net&quot;, // for Pattern 3 later in this guide
      },
    ],
  },
};

export default nextConfig;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting this step and assuming &lt;code&gt;next/image&lt;/code&gt; is broken. The error Next.js throws (&quot;hostname is not configured&quot;) is exactly this — it's not a bug, it's the optimizer refusing to fetch from an unlisted domain.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you're serving stable public URLs (Pattern 1 or the public side of Pattern 3), also tune &lt;code&gt;minimumCacheTTL&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;images: {
  minimumCacheTTL: 60 * 60 * 24 * 30, // cache optimized output for 30 days
  remotePatterns: [/* ... */],
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default, Next.js only caches an optimized image for 4 hours before re-checking the source. For images that never change once uploaded (like avatars or product photos with unique keys), a much longer TTL avoids repeated, unnecessary re-optimization of the same file.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-1-public-bucket-urls&quot;&gt;Pattern 1: Public Bucket URLs&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; the object is publicly readable, so its S3 URL works for anyone, forever — no signing, no expiry, no server involvement at all.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → S3 object URL directly (no server request needed)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the right call for genuinely non-sensitive assets: blog cover images, public product photos, avatars a user chose to make visible. It's also the fastest pattern to build, since there's no backend logic involved in displaying the image at all.&lt;/p&gt;
&lt;h4 id=&quot;step-1-allow-public-reads-on-specific-objects&quot;&gt;Step 1: Allow Public Reads on Specific Objects&lt;/h4&gt;
&lt;p&gt;Rather than making the whole bucket public (don't do that), scope a bucket policy to just the folder holding public assets:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// s3-public-read-policy.json
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Sid&quot;: &quot;PublicReadForPublicFolder&quot;,
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: &quot;*&quot;,
      &quot;Action&quot;: &quot;s3:GetObject&quot;,
      &quot;Resource&quot;: &quot;arn:aws:s3:::devstacked-uploads-demo/public/*&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; this policy only opens read access to objects under the &lt;code&gt;public/&lt;/code&gt; prefix — everything else in the bucket (like &lt;code&gt;uploads/&lt;/code&gt; or &lt;code&gt;large-uploads/&lt;/code&gt; from the upload guide) stays private by default.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; If your bucket still has &lt;strong&gt;Block all public access&lt;/strong&gt; fully enabled (from the upload guide's setup), AWS will reject this policy outright with an &quot;access denied&quot; error, even though the policy itself is valid. Go to your bucket → &lt;strong&gt;Permissions → Block public access&lt;/strong&gt; and uncheck the two bucket policy &lt;strong&gt;Block Public Access&lt;/strong&gt; settings while leaving the ACL-related settings enabled. This allows a scoped public bucket policy without relying on public ACLs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Applying a public-read policy to the entire bucket instead of a scoped prefix. If your upload code ever writes a sensitive file without thinking to change the key, it becomes silently public the moment it lands.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-store-the-public-url-format&quot;&gt;Step 2: Store the Public URL Format&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/s3-urls.ts
const BUCKET_NAME = process.env.S3_BUCKET_NAME!;
const REGION = process.env.AWS_REGION!;

export function getPublicUrl(key: string): string {
  // encodeURIComponent handles spaces and special characters in the key —
  // a raw key like &quot;public/my photo.jpg&quot; produces an invalid URL without it
  const encodedKey = key.split(&quot;/&quot;).map(encodeURIComponent).join(&quot;/&quot;);
  return `https://${BUCKET_NAME}.s3.${REGION}.amazonaws.com/${encodedKey}`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since public URLs never expire and never need signing, you can build this string anywhere — no API call needed, not even to your own server.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Encoding the whole key with a single &lt;code&gt;encodeURIComponent(key)&lt;/code&gt; call. That also escapes the &lt;code&gt;/&lt;/code&gt; characters in the path, turning &lt;code&gt;public/avatars/photo.jpg&lt;/code&gt; into a single garbled segment instead of a valid nested path. Encode each path segment individually, as shown above.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-3-render-it-with-next-image&quot;&gt;Step 3: Render It with next/image&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/public-avatar.tsx
import Image from &quot;next/image&quot;;
import { getPublicUrl } from &quot;@/lib/s3-urls&quot;;

interface Props {
  objectKey: string; // e.g. &quot;public/avatars/uuid-photo.jpg&quot;
  alt: string;
}

export function PublicAvatar({ objectKey, alt }: Props) {
  return (
    &amp;lt;Image
      src={getPublicUrl(objectKey)}
      alt={alt}
      width={96}
      height={96}
      className=&quot;rounded-full object-cover&quot;
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because we added the S3 hostname to &lt;code&gt;remotePatterns&lt;/code&gt; earlier, Next.js is happy to optimize this image — resizing, format conversion (like WebP), and lazy loading all work exactly like they would with a local image.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-2-presigned-get-urls-for-private-files&quot;&gt;Pattern 2: Presigned GET URLs for Private Files&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; the object stays fully private in S3. When a user is allowed to view it, your server generates a short-lived, signed URL — just like the presigned upload URLs from the upload guide, but for reading instead of writing.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → Next.js (ask &quot;can I see this file?&quot;) → gets presigned GET URL
Browser → S3 directly, using that URL, before it expires
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the mirror image of the presigned upload pattern: same idea, opposite direction. Use it for anything user-specific — private documents, someone's uploaded ID photo, a paid course's video thumbnail.&lt;/p&gt;
&lt;h4 id=&quot;step-1-generate-the-presigned-get-url&quot;&gt;Step 1: Generate the Presigned GET URL&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/image-url/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { GetObjectCommand } from &quot;@aws-sdk/client-s3&quot;;
import { getSignedUrl } from &quot;@aws-sdk/s3-request-presigner&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { auth } from &quot;@/lib/auth&quot;; // your auth solution of choice

const URL_EXPIRY_SECONDS = 300; // 5 minutes

export async function GET(request: NextRequest) {
  const key = request.nextUrl.searchParams.get(&quot;key&quot;);
  const session = await auth();

  if (!session?.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  if (!key || !key.startsWith(`private/${session.userId}/`)) {
    return NextResponse.json({ error: &quot;Forbidden&quot; }, { status: 403 });
  }

  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: key,
  });

  const url = await getSignedUrl(s3Client, command, {
    expiresIn: URL_EXPIRY_SECONDS,
  });

  return NextResponse.json({ url });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We check the user is authenticated &lt;strong&gt;before&lt;/strong&gt; doing anything else — a presigned URL is only as safe as the check that generates it&lt;/li&gt;
&lt;li&gt;The key check (&lt;code&gt;key.startsWith('private/${session.userId}/')&lt;/code&gt;) makes sure a user can only ever get a signed URL for &lt;strong&gt;their own&lt;/strong&gt; files, not someone else's, even if they know another user's exact key&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expiresIn: 300&lt;/code&gt; means the link works for 5 minutes — long enough to load the image, short enough that a copied link goes stale quickly&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Generating a presigned GET URL without checking &lt;em&gt;who's asking&lt;/em&gt;. It's easy to think &quot;it expires, so it's safe&quot; — but a 5-minute window is still plenty of time for a leaked link to be misused if you skip the ownership check.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-fetch-and-display-it-client-side&quot;&gt;Step 2: Fetch and Display It Client-Side&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/private-image.tsx
&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import Image from &quot;next/image&quot;;

interface Props {
  objectKey: string;
  alt: string;
}

export function PrivateImage({ objectKey, alt }: Props) {
  const [url, setUrl] = useState&amp;lt;string | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    let cancelled = false;

    fetch(`/api/image-url?key=${encodeURIComponent(objectKey)}`)
      .then((res) =&amp;gt; res.json())
      .then((data) =&amp;gt; {
        if (!cancelled) setUrl(data.url ?? null);
      });

    return () =&amp;gt; {
      cancelled = true;
    };
  }, [objectKey]);

  if (!url) {
    return &amp;lt;div className=&quot;h-24 w-24 rounded-lg bg-muted animate-pulse&quot; /&amp;gt;;
  }

  return (
    &amp;lt;Image
      src={url}
      alt={alt}
      width={96}
      height={96}
      unoptimized // presigned URLs change every load, so caching them via the optimizer adds little value
      className=&quot;rounded-lg object-cover&quot;
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;unoptimized&lt;/code&gt;?&lt;/strong&gt; Next.js's image optimizer caches the optimized result by URL. Since a presigned URL is different every single time it's generated, the optimizer would just be doing wasted work re-processing an image that's already the right size coming out of S3. Skipping optimization here is the correct call, not a shortcut.&lt;/p&gt;
&lt;p&gt;This flag only bypasses &lt;strong&gt;Next.js's own optimization pipeline&lt;/strong&gt; — it doesn't disable the browser's normal HTTP caching. The browser can still cache the actual image bytes according to whatever &lt;code&gt;Cache-Control&lt;/code&gt; header S3 returns on the object itself. Many developers assume &lt;code&gt;unoptimized&lt;/code&gt; means &quot;never cached at all,&quot; which isn't true — it just means Next.js won't resize, re-encode, or store its own separate optimized copy of this particular image.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; For a server component instead of a client component, you can generate the presigned URL directly during the render on the server — no &lt;code&gt;useEffect&lt;/code&gt;, no loading flicker, no extra round trip. Reach for the client-side version above only when the key isn't known until after the page has already loaded.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-3-cloudfront-cdn-delivery&quot;&gt;Pattern 3: CloudFront CDN Delivery&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; instead of the browser talking to S3 directly, it talks to a &lt;strong&gt;CloudFront distribution&lt;/strong&gt; — a CDN that sits in front of your bucket, caches responses at edge locations close to your users, and can serve either public or signed content.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → CloudFront (edge location near the user) → S3 (only on a cache miss)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Think of it like the difference between calling a single warehouse for every order versus having regional distribution centers — most requests get served from somewhere nearby instead of the original source every time.&lt;/p&gt;
&lt;p&gt;This pattern matters once you have real traffic: it cuts latency, reduces direct load (and cost) on your S3 bucket, and gives you one clean domain to serve images from instead of raw S3 hostnames.&lt;/p&gt;
&lt;h4 id=&quot;step-1-create-a-cloudfront-distribution&quot;&gt;Step 1: Create a CloudFront Distribution&lt;/h4&gt;
&lt;p&gt;This is the part most tutorials gloss over, so let's walk through it fully — this guide is meant to get you a working setup, not just a conceptual overview.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;a) Create the distribution&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In the AWS Console under &lt;strong&gt;CloudFront → Distributions → Create distribution&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Under &lt;strong&gt;Origin domain&lt;/strong&gt;, click into the field and select your S3 bucket from the dropdown (it appears automatically once you start typing the bucket name)&lt;/li&gt;
&lt;li&gt;Leave &lt;strong&gt;Origin path&lt;/strong&gt; empty unless you want CloudFront to only serve a subfolder of your bucket&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Viewer protocol policy&lt;/strong&gt;, choose &lt;strong&gt;Redirect HTTP to HTTPS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Leave the rest of the defaults for now and click &lt;strong&gt;Create distribution&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Creation takes a few minutes to fully deploy — CloudFront needs to propagate your distribution to edge locations worldwide before it's live everywhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;b) Attach Origin Access Control (for private objects)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If your bucket (or the folder you're serving) is private, you need CloudFront to be the &lt;em&gt;only&lt;/em&gt; thing allowed to read from it directly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open your distribution → go to the &lt;strong&gt;Origins&lt;/strong&gt; tab → select your origin → click &lt;strong&gt;Edit&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Origin access control settings&lt;/strong&gt;, choose &lt;strong&gt;Origin access control settings (recommended)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create control setting&lt;/strong&gt;, give it a name, and leave &quot;Sign requests&quot; as the default&lt;/li&gt;
&lt;li&gt;Save — CloudFront will show a banner warning that the bucket policy still needs updating. That's expected; that's the next step.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;c) Update the bucket policy to allow CloudFront&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;AWS actually generates the exact policy JSON for you at the end of step (b) — look for a &lt;strong&gt;&quot;Copy policy&quot;&lt;/strong&gt; button in that same banner. It looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// s3-cloudfront-oac-policy.json
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Sid&quot;: &quot;AllowCloudFrontServicePrincipal&quot;,
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: { &quot;Service&quot;: &quot;cloudfront.amazonaws.com&quot; },
      &quot;Action&quot;: &quot;s3:GetObject&quot;,
      &quot;Resource&quot;: &quot;arn:aws:s3:::devstacked-uploads-demo/*&quot;,
      &quot;Condition&quot;: {
        &quot;StringEquals&quot;: {
          &quot;AWS:SourceArn&quot;: &quot;arn:aws:cloudfront::123456789012:distribution/EXAMPLE_ID&quot;
        }
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Paste this into your bucket → &lt;strong&gt;Permissions → Bucket policy&lt;/strong&gt;. If you also have the public-read policy from Pattern 1, both statements can coexist in the same policy's &lt;code&gt;Statement&lt;/code&gt; array — one scopes access to CloudFront's service principal, the other to &lt;code&gt;Principal: &quot;*&quot;&lt;/code&gt; under the &lt;code&gt;public/&lt;/code&gt; prefix.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Copying an OAC policy example from an older tutorial that references the deprecated &lt;strong&gt;Origin Access Identity (OAI)&lt;/strong&gt; instead of &lt;strong&gt;OAC&lt;/strong&gt;. AWS now recommends OAC for all new distributions — OAI still works but lacks OAC's support for all S3 request types and encryption options.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;d) Get your CloudFront domain&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Once the distribution's status shows &lt;strong&gt;Enabled&lt;/strong&gt; (back on the distributions list), copy the value under &lt;strong&gt;Domain name&lt;/strong&gt; — it looks like &lt;code&gt;d111111abcdef8.cloudfront.net&lt;/code&gt;. That's the &lt;code&gt;CLOUDFRONT_DOMAIN&lt;/code&gt; value you'll use in the code below. If you own a custom domain, you can also attach it under &lt;strong&gt;Alternate domain names (CNAMEs)&lt;/strong&gt; with a matching ACM certificate, but the default &lt;code&gt;.cloudfront.net&lt;/code&gt; domain works fine to get started.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Setting up CloudFront in front of a bucket but forgetting to update the bucket policy to allow the CloudFront OAC principal. Without it, CloudFront gets a 403 from S3 on every request, which looks like a CloudFront bug but is actually a permissions gap between the two services.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-serve-public-assets-through-cloudfront&quot;&gt;Step 2: Serve Public Assets Through CloudFront&lt;/h4&gt;
&lt;p&gt;For public assets, this is a one-line change from Pattern 1 — swap the S3 hostname for your CloudFront domain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/s3-urls.ts
const CLOUDFRONT_DOMAIN = process.env.CLOUDFRONT_DOMAIN!; // e.g. &quot;d111111abcdef8.cloudfront.net&quot;

export function getPublicUrl(key: string): string {
  return `https://${CLOUDFRONT_DOMAIN}/${key}`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing else about Pattern 1's code changes — same &lt;code&gt;next/image&lt;/code&gt; component, same &lt;code&gt;remotePatterns&lt;/code&gt; entry (already added in setup), just a faster, cached path to the same file.&lt;/p&gt;
&lt;h4 id=&quot;step-3-serve-private-assets-with-signed-cloudfront-urls&quot;&gt;Step 3: Serve Private Assets with Signed CloudFront URLs&lt;/h4&gt;
&lt;p&gt;For private files behind CloudFront, you sign the CloudFront URL itself instead of the S3 URL. This needs a CloudFront key pair rather than your IAM credentials:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/cloudfront-signer.ts
import { getSignedUrl } from &quot;@aws-sdk/cloudfront-signer&quot;;

const CLOUDFRONT_DOMAIN = process.env.CLOUDFRONT_DOMAIN!;
const PRIVATE_KEY = process.env.CLOUDFRONT_PRIVATE_KEY!;
const KEY_PAIR_ID = process.env.CLOUDFRONT_KEY_PAIR_ID!;

export function getSignedCloudFrontUrl(key: string): string {
  return getSignedUrl({
    url: `https://${CLOUDFRONT_DOMAIN}/${key}`,
    keyPairId: KEY_PAIR_ID,
    privateKey: PRIVATE_KEY,
    dateLessThan: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install the extra package this needs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @aws-sdk/cloudfront-signer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;getSignedUrl&lt;/code&gt; from the CloudFront signer package works conceptually the same as the S3 presigner from Pattern 2 — it appends signed query parameters that CloudFront checks before serving the file — but it's signed with a dedicated CloudFront key pair, not your regular IAM credentials.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where to create that key pair:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In the AWS Console, go to &lt;strong&gt;CloudFront → Key management → Public keys → Create public key&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;This requires an RSA key pair. Generate one locally first:&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Paste the contents of &lt;code&gt;public_key.pem&lt;/code&gt; into the &lt;strong&gt;Create public key&lt;/strong&gt; form and save&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Key management → Key groups → Create key group&lt;/strong&gt;, and add the public key you just created to it&lt;/li&gt;
&lt;li&gt;Back in your distribution's &lt;strong&gt;Behaviors&lt;/strong&gt; tab, edit the relevant behavior and set &lt;strong&gt;Restrict viewer access&lt;/strong&gt; to &lt;strong&gt;Yes&lt;/strong&gt;, then select your key group under &lt;strong&gt;Trusted key groups&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Note the &lt;strong&gt;Key ID&lt;/strong&gt; shown after creating the public key — that's your &lt;code&gt;CLOUDFRONT_KEY_PAIR_ID&lt;/code&gt;. The contents of &lt;code&gt;private_key.pem&lt;/code&gt; (never the public key) go into &lt;code&gt;CLOUDFRONT_PRIVATE_KEY&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Adding the public key to CloudFront but forgetting step 5 — setting &lt;strong&gt;Restrict viewer access&lt;/strong&gt; on the actual behavior. Without that, CloudFront still serves the file to anyone, signed URL or not, because nothing told it signing was required in the first place.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;You'd call this the same way as Pattern 2's route handler — check the requesting user owns the file, then return the signed CloudFront URL instead of a signed S3 URL.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If most of your private files belong to logged-in users viewing many images per page (a dashboard, a gallery), look into &lt;strong&gt;signed cookies&lt;/strong&gt; instead of signed URLs. One signed cookie can authorize access to many files at once, instead of generating and re-fetching a fresh signed URL per image.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;which-pattern-should-you-actually-use-&quot;&gt;Which Pattern Should You Actually Use?&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Public Bucket URLs &lt;/th&gt;&lt;th&gt;Presigned GET URLs &lt;/th&gt;&lt;th&gt;CloudFront CDN&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Setup complexity&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Low — one bucket policy &lt;/td&gt;&lt;td&gt;Medium — auth check + route handler &lt;/td&gt;&lt;td&gt;Higher — distribution, OAC, key pairs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Latency&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Depends on bucket region &lt;/td&gt;&lt;td&gt;Depends on bucket region &lt;/td&gt;&lt;td&gt;Low — served from nearby edge locations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Caching&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Browser cache only &lt;/td&gt;&lt;td&gt;Effectively none (URL changes each time) &lt;/td&gt;&lt;td&gt;Edge-cached, huge win at scale&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Access control&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;None — anyone with the URL &lt;/td&gt;&lt;td&gt;Per-request, ownership-checked &lt;/td&gt;&lt;td&gt;Optional — public or signed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Non-sensitive, low-to-medium traffic assets &lt;/td&gt;&lt;td&gt;Private, user-specific files &lt;/td&gt;&lt;td&gt;Any file type at real scale&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Pattern&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Blog covers, public product photos, anything anyone can see &lt;/td&gt;&lt;td&gt;Public bucket URLs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Small app, low traffic, simplicity matters more than speed &lt;/td&gt;&lt;td&gt;Public bucket URLs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Private user documents, receipts, anything tied to one account &lt;/td&gt;&lt;td&gt;Presigned GET URLs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;You need an ownership check before showing the file &lt;/td&gt;&lt;td&gt;Presigned GET URLs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Public images at real scale — you want edge caching and lower latency &lt;/td&gt;&lt;td&gt;CloudFront (public)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Private files at scale, or many private images per page &lt;/td&gt;&lt;td&gt;CloudFront (signed URLs or cookies)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A common real-world setup uses &lt;strong&gt;all three at once&lt;/strong&gt;: public marketing assets served through CloudFront for speed, private user uploads gated by presigned URLs (or signed CloudFront URLs once traffic grows), all coming from the same bucket you set up in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16&quot;&gt;upload guide&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-1&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;can-i-just-use-a-plain-lt-img-gt-tag-instead-of-next-image-&quot;&gt;Can I just use a plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag instead of next/image?&lt;/h4&gt;
&lt;p&gt;Yes, especially for presigned URLs that change on every load, where &lt;code&gt;next/image&lt;/code&gt;'s optimization caching adds little value anyway. &lt;code&gt;next/image&lt;/code&gt; is worth it for public, stable URLs where resizing and format conversion (WebP/AVIF) genuinely save bandwidth.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-cloudfront-if-i-m-only-using-presigned-urls-&quot;&gt;Do I need CloudFront if I'm only using presigned URLs?&lt;/h4&gt;
&lt;p&gt;Not necessarily. Presigned URLs work fine served directly from S3. CloudFront becomes worth the setup once you care about latency at scale or want to reduce direct requests hitting your bucket — it's a performance and cost optimization, not a requirement for private files to work.&lt;/p&gt;
&lt;h4 id=&quot;why-does-my-s3-image-403-even-though-i-set-a-bucket-policy-&quot;&gt;Why does my S3 image 403 even though I set a bucket policy?&lt;/h4&gt;
&lt;p&gt;Usually one of two things: the object key in your policy's &lt;code&gt;Resource&lt;/code&gt; doesn't actually match where the file lives, or — if you're using CloudFront with Origin Access Control — the bucket policy hasn't been updated to allow the CloudFront principal specifically, separate from any public-read policy.&lt;/p&gt;
&lt;h4 id=&quot;should-i-store-the-s3-key-or-the-full-url-in-my-database-&quot;&gt;Should I store the S3 key or the full URL in my database?&lt;/h4&gt;
&lt;p&gt;Store the key (e.g. &lt;code&gt;public/avatars/uuid.jpg&lt;/code&gt;), not the full URL. Keys never change even if you switch from raw S3 URLs to CloudFront later, rename your bucket, or move regions — you'd just update the URL-building function, not every database row.&lt;/p&gt;
&lt;h4 id=&quot;can-i-resize-images-on-the-fly-instead-of-storing-multiple-sizes-&quot;&gt;Can I resize images on the fly instead of storing multiple sizes?&lt;/h4&gt;
&lt;p&gt;Yes — a common pattern is a CloudFront distribution with a Lambda@Edge or CloudFront Function that resizes images based on query parameters the first time they're requested, then caches the result. That's a bigger setup than this guide covers, but it's the natural next step once &lt;code&gt;next/image&lt;/code&gt;'s built-in optimization isn't enough for your use case.&lt;/p&gt;
&lt;h4 id=&quot;why-does-my-image-download-instead-of-render-in-the-browser-&quot;&gt;Why does my image download instead of render in the browser?&lt;/h4&gt;
&lt;p&gt;This happens when the object's &lt;code&gt;Content-Disposition&lt;/code&gt; header is set to &lt;code&gt;attachment&lt;/code&gt; instead of &lt;code&gt;inline&lt;/code&gt; — usually because it was uploaded with that metadata set explicitly, or a client library defaulted to it. Fix it by setting the header correctly at upload time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;new PutObjectCommand({
  Bucket: process.env.S3_BUCKET_NAME!,
  Key: key,
  Body: buffer,
  ContentType: file.type,
  ContentDisposition: &quot;inline&quot;, // ensures browsers render it instead of downloading it
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If files are already uploaded with the wrong header, you can fix them without re-uploading by copying the object onto itself with &lt;code&gt;CopyObjectCommand&lt;/code&gt; and the corrected &lt;code&gt;ContentDisposition&lt;/code&gt; and &lt;code&gt;MetadataDirective: &quot;REPLACE&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;You now have three complete patterns for displaying S3 images in Next.js 16 — plain public URLs for anything non-sensitive, presigned GET URLs for anything private and user-specific, and CloudFront for when performance and scale start to matter. Paired with the &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16&quot;&gt;upload guide&lt;/a&gt;, you've got the full loop covered: getting files into S3 the right way, and getting them back out safely and fast.&lt;/p&gt;
&lt;p&gt;From here, natural next steps include adding on-the-fly image resizing at the CDN layer, setting up signed cookies for pages with many private images, or wiring up automatic thumbnail generation with S3 Event Notifications when a file finishes uploading.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/next-s3-setup&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;continue-learning-1&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16&quot;&gt;Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy &amp;amp; Multipart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR &amp;amp; Cache Components (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23980/how-to-display-images-from-s3-in-next-js-16-2026-guide</guid>
<pubDate>Sun, 02 Aug 2026 06:14:55 +0000</pubDate>
</item>
<item>
<title>Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy &amp; Multipart</title>
<link>https://coderlegion.com/23910/next-js-16-aws-s3-file-upload-guide-presigned-urls-proxy-multipart</link>
<description>&lt;p&gt;At some point, almost every real app needs file uploads — profile pictures, PDFs, videos, CSV imports. The tempting move is to just &lt;code&gt;POST&lt;/code&gt; the file straight to your Next.js server and save it somewhere. That works great in a tutorial and falls apart the moment a real user uploads a 200MB video, because your serverless function times out, hits its body size limit, or just burns money streaming bytes it didn't need to touch.&lt;/p&gt;
&lt;p&gt;The fix is almost always &lt;strong&gt;Amazon S3&lt;/strong&gt; (Simple Storage Service) — a place built specifically to store files cheaply and reliably, so your Next.js app doesn't have to. But &quot;just use S3&quot; isn't one pattern, it's three, and picking the wrong one is why most S3 upload tutorials online feel incomplete or break in production.&lt;/p&gt;
&lt;p&gt;In this guide, you'll build all three real-world upload patterns in Next.js 16:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Server-side proxying&lt;/strong&gt; — the file passes through your Next.js server on its way to S3&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Direct-to-S3 presigned URLs&lt;/strong&gt; — the browser uploads straight to S3, skipping your server entirely&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multipart uploads&lt;/strong&gt; — for large files, split into chunks so they upload reliably and can resume&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By the end, you'll know exactly which pattern to reach for and have working code for each.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky-1&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Here's the core tension: your Next.js server (especially on serverless platforms like Vercel) is not a good place for large files to pass through.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Body size limits&lt;/strong&gt; — most hosting platforms cap how much data a single request can carry (Vercel's default is 4.5MB for serverless functions).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Function timeouts&lt;/strong&gt; — a slow upload over a weak connection can outlive your function's execution limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You pay for time, not just requests&lt;/strong&gt; — every second your function spends waiting on a file transfer is a second you're billed for, even though your server isn't doing any real work.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the real skill here isn't &quot;how do I upload to S3&quot; — it's &quot;how much of this upload actually needs to touch my server?&quot; That question is what decides which of the three patterns you reach for.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; As a rule of thumb — small files (avatars, documents under a few MB) are fine to proxy through your server. Anything user-facing and potentially large (videos, zip files, high-res images) should go direct-to-S3.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites-amp-setup-1&quot;&gt;Prerequisites &amp;amp; Setup&lt;/h3&gt;
&lt;p&gt;This guide assumes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the (minimal) UI examples&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;AWS account&lt;/strong&gt; with an S3 bucket&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;step-1-create-an-s3-bucket&quot;&gt;Step 1: Create an S3 Bucket&lt;/h4&gt;
&lt;p&gt;In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://console.aws.amazon.com/s3/&quot;&gt;AWS Console&lt;/a&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Create bucket&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Give it a globally unique name, e.g. &lt;code&gt;devstacked-uploads-demo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Pick a region close to your users&lt;/li&gt;
&lt;li&gt;Leave &lt;strong&gt;Block all public access&lt;/strong&gt; checked — you don't need a public bucket for any of these three patterns&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Making the bucket public &quot;to make uploads easier.&quot; None of the patterns in this guide need a public bucket. Presigned URLs grant temporary, scoped access without ever exposing the bucket itself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-create-an-iam-user-for-programmatic-access&quot;&gt;Step 2: Create an IAM User for Programmatic Access&lt;/h4&gt;
&lt;p&gt;Your Next.js server needs credentials to talk to S3.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;IAM → Users → Create user&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Name it something like &lt;code&gt;nextjs-s3-uploader&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Attach a policy scoped to just this bucket — not full &lt;code&gt;AmazonS3FullAccess&lt;/code&gt;:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// s3-upload-policy.json
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Action&quot;: [
        &quot;s3:PutObject&quot;,
        &quot;s3:GetObject&quot;,
        &quot;s3:AbortMultipartUpload&quot;,
        &quot;s3:ListMultipartUploadParts&quot;
      ],
      &quot;Resource&quot;: &quot;arn:aws:s3:::devstacked-uploads-demo/*&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;After creating the user, open it and generate an &lt;strong&gt;Access key&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Using your AWS root account credentials in &lt;code&gt;.env.local&lt;/code&gt;. Always create a scoped IAM user — if those keys ever leak, the blast radius is one bucket, not your entire AWS account.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-3-configure-cors-on-the-bucket&quot;&gt;Step 3: Configure CORS on the Bucket&lt;/h4&gt;
&lt;p&gt;Two of the three patterns in this guide (presigned URLs and multipart) have the &lt;strong&gt;browser&lt;/strong&gt; talk directly to S3. By default, S3 blocks cross-origin requests from your site, so you need to explicitly allow it.&lt;/p&gt;
&lt;p&gt;In your bucket → &lt;strong&gt;Permissions → Cross-origin resource sharing (CORS)&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// s3-cors-config.json
[
  {
    &quot;AllowedHeaders&quot;: [&quot;*&quot;],
    &quot;AllowedMethods&quot;: [&quot;PUT&quot;, &quot;POST&quot;, &quot;GET&quot;],
    &quot;AllowedOrigins&quot;: [&quot;http://localhost:3000&quot;, &quot;https://yourdomain.com&quot;],
    &quot;ExposeHeaders&quot;: [&quot;ETag&quot;]
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;ExposeHeaders: [&quot;ETag&quot;]&lt;/code&gt; matters:&lt;/strong&gt; the multipart pattern later needs to read the &lt;code&gt;ETag&lt;/code&gt; header back from S3 after each chunk uploads. Without exposing it, your browser can see the response succeeded but can't read the header it needs.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Consider enabling S3 Versioning from &lt;code&gt;Properties Tab&lt;/code&gt; if users may overwrite files. It provides recovery from accidental deletes or replacements.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-4-install-dependencies&quot;&gt;Step 4: Install Dependencies&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@aws-sdk/client-s3&lt;/code&gt; — the official AWS SDK v3 client for talking to S3&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@aws-sdk/s3-request-presigner&lt;/code&gt; — generates temporary, signed upload URLs&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;step-5-environment-variables&quot;&gt;Step 5: Environment Variables&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
S3_BUCKET_NAME=devstacked-uploads-demo
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Never prefix these with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. That would ship your AWS secret key to every visitor's browser. These stay server-only, always.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-6-create-a-reusable-s3-client&quot;&gt;Step 6: Create a Reusable S3 Client&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/s3-client.ts
import { S3Client } from &quot;@aws-sdk/client-s3&quot;;

export const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every pattern below imports this single client instead of creating a new one per request.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-1-server-side-proxying&quot;&gt;Pattern 1: Server-Side Proxying&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; the browser sends the file to your Next.js server, and your server forwards it to S3. Your server sits in the middle of every byte.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → Next.js Route Handler → S3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the simplest pattern to reason about and the easiest to add validation, virus scanning, or image processing to — because your server actually sees the file. The tradeoff is exactly what we covered above: your server pays the time and bandwidth cost of the transfer, so this pattern is best kept for &lt;strong&gt;small files only&lt;/strong&gt; (a few MB at most).&lt;/p&gt;
&lt;h4 id=&quot;step-1-create-the-upload-route-handler&quot;&gt;Step 1: Create the Upload Route Handler&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/upload/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { PutObjectCommand } from &quot;@aws-sdk/client-s3&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = [&quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;image/webp&quot;];

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get(&quot;file&quot;) as File | null;

    if (!file) {
      return NextResponse.json({ error: &quot;No file provided&quot; }, { status: 400 });
    }

    if (!ALLOWED_TYPES.includes(file.type)) {
      return NextResponse.json({ error: &quot;Unsupported file type&quot; }, { status: 400 });
    }

    if (file.size &amp;gt; MAX_FILE_SIZE) {
      return NextResponse.json({ error: &quot;File too large&quot; }, { status: 400 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const key = `avatars/${randomUUID()}-${file.name}`;

    await s3Client.send(
      new PutObjectCommand({
        Bucket: process.env.S3_BUCKET_NAME!,
        Key: key,
        Body: buffer,
        ContentType: file.type,
      })
    );

    return NextResponse.json({ key }, { status: 200 });
  } catch (error) {
    console.error(&quot;Upload error:&quot;, error);
    return NextResponse.json({ error: &quot;Upload failed&quot; }, { status: 500 });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;request.formData()&lt;/code&gt; reads the incoming multipart form data — this is how browsers send files&lt;/li&gt;
&lt;li&gt;We validate &lt;code&gt;file.type&lt;/code&gt; and &lt;code&gt;file.size&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; touching S3, so bad requests fail fast and cheap&lt;/li&gt;
&lt;li&gt;&lt;code&gt;file.arrayBuffer()&lt;/code&gt; converts the browser &lt;code&gt;File&lt;/code&gt; object into a &lt;code&gt;Buffer&lt;/code&gt; that the AWS SDK can send&lt;/li&gt;
&lt;li&gt;The S3 object &lt;code&gt;Key&lt;/code&gt; (its path inside the bucket) includes a random UUID so two people uploading &lt;code&gt;photo.jpg&lt;/code&gt; never overwrite each other&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Trusting &lt;code&gt;file.type&lt;/code&gt; alone for security. It's sent by the browser and can be spoofed. For anything sensitive, also validate the actual file signature (magic bytes) server-side, or run the file through a scanning service before it's considered &quot;safe.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-build-the-upload-form&quot;&gt;Step 2: Build the Upload Form&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/proxy-upload-form.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;

export function ProxyUploadForm() {
  const [uploading, setUploading] = useState(false);
  const [message, setMessage] = useState(&quot;&quot;);

  const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    const form = e.currentTarget;
    const fileInput = form.elements.namedItem(&quot;file&quot;) as HTMLInputElement;
    const file = fileInput.files?.[0];

    if (!file) return;

    setUploading(true);
    setMessage(&quot;&quot;);

    const formData = new FormData();
    formData.append(&quot;file&quot;, file);

    const res = await fetch(&quot;/api/upload&quot;, {
      method: &quot;POST&quot;,
      body: formData,
    });

    const data = await res.json();
    setUploading(false);
    setMessage(res.ok ? `Uploaded: ${data.key}` : data.error);
  };

  return (
    &amp;lt;form onSubmit={handleSubmit} className=&quot;flex flex-col gap-3 max-w-sm&quot;&amp;gt;
      &amp;lt;input type=&quot;file&quot; name=&quot;file&quot; accept=&quot;image/*&quot; required /&amp;gt;
      &amp;lt;button
        type=&quot;submit&quot;
        disabled={uploading}
        className=&quot;rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50&quot;
      &amp;gt;
        {uploading ? &quot;Uploading...&quot; : &quot;Upload&quot;}
      &amp;lt;/button&amp;gt;
      {message &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;{message}&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing exotic here — a standard &lt;code&gt;FormData&lt;/code&gt; submission to our route handler. This pattern is genuinely the easiest to get right, which is exactly why it's the wrong choice once files get bigger.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-2-direct-to-s3-presigned-urls&quot;&gt;Pattern 2: Direct-to-S3 Presigned URLs&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; your server never touches the file. Instead, it asks S3 for a &lt;strong&gt;presigned URL&lt;/strong&gt; — a temporary, signed link that grants permission to upload one specific file, to one specific location, for a short window of time. The browser then uploads directly to S3 using that URL.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → Next.js (ask for permission) → gets presigned URL
Browser → S3 directly (actual file upload)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Think of a presigned URL like a &lt;strong&gt;guest pass&lt;/strong&gt;: your server is the front desk that checks you're allowed in, then hands you a pass that only works for one door, for the next few minutes. You still never got a master key to the building.&lt;/p&gt;
&lt;p&gt;This is the pattern you want for anything user-facing that could be more than a few MB — profile videos, portfolio images, document uploads. Your server's job shrinks down to a single, fast decision: &quot;yes, generate a URL&quot; or &quot;no.&quot;&lt;/p&gt;
&lt;h4 id=&quot;step-1-generate-the-presigned-url&quot;&gt;Step 1: Generate the Presigned URL&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/presigned-url/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { PutObjectCommand } from &quot;@aws-sdk/client-s3&quot;;
import { getSignedUrl } from &quot;@aws-sdk/s3-request-presigner&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;

const ALLOWED_TYPES = [&quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;video/mp4&quot;];
const URL_EXPIRY_SECONDS = 60; // presigned URL is valid for 60 seconds

export async function POST(request: NextRequest) {
  try {
    const { fileName, fileType } = await request.json();

    if (!fileName || !fileType) {
      return NextResponse.json({ error: &quot;Missing fileName or fileType&quot; }, { status: 400 });
    }

    if (!ALLOWED_TYPES.includes(fileType)) {
      return NextResponse.json({ error: &quot;Unsupported file type&quot; }, { status: 400 });
    }

    const key = `uploads/${randomUUID()}-${fileName}`;

    const command = new PutObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: key,
      ContentType: fileType,
    });

    const uploadUrl = await getSignedUrl(s3Client, command, {
      expiresIn: URL_EXPIRY_SECONDS,
    });

    return NextResponse.json({ uploadUrl, key });
  } catch (error) {
    console.error(&quot;Presign error:&quot;, error);
    return NextResponse.json({ error: &quot;Could not generate upload URL&quot; }, { status: 500 });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We never receive the actual file — just its &lt;code&gt;fileName&lt;/code&gt; and &lt;code&gt;fileType&lt;/code&gt;, which is why this endpoint responds instantly no matter how big the file will be&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getSignedUrl()&lt;/code&gt; doesn't make a network call to AWS — it cryptographically signs the request locally using your IAM credentials, so it's fast&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expiresIn: 60&lt;/code&gt; means this exact URL stops working after 60 seconds — plenty of time for the browser to start the upload, but not so long that a leaked URL stays exploitable&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Still validate &lt;code&gt;fileType&lt;/code&gt; here even though you're not seeing the actual bytes. It's your only real gate at this stage — S3 will happily accept whatever &lt;code&gt;ContentType&lt;/code&gt; the presigned request specifies.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-upload-directly-from-the-browser&quot;&gt;Step 2: Upload Directly From the Browser&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/presigned-upload-form.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;

export function PresignedUploadForm() {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState&amp;lt;&quot;idle&quot; | &quot;uploading&quot; | &quot;done&quot; | &quot;error&quot;&amp;gt;(&quot;idle&quot;);

  const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    const form = e.currentTarget;
    const fileInput = form.elements.namedItem(&quot;file&quot;) as HTMLInputElement;
    const file = fileInput.files?.[0];
    if (!file) return;

    setStatus(&quot;uploading&quot;);
    setProgress(0);

    // Step 1: ask our server for a presigned URL
    const presignRes = await fetch(&quot;/api/presigned-url&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ fileName: file.name, fileType: file.type }),
    });

    if (!presignRes.ok) {
      setStatus(&quot;error&quot;);
      return;
    }

    const { uploadUrl } = await presignRes.json();

    // Step 2: upload the actual file straight to S3, tracking progress
    await new Promise&amp;lt;void&amp;gt;((resolve, reject) =&amp;gt; {
      const xhr = new XMLHttpRequest();
      xhr.open(&quot;PUT&quot;, uploadUrl);
      xhr.setRequestHeader(&quot;Content-Type&quot;, file.type);

      xhr.upload.onprogress = (event) =&amp;gt; {
        if (event.lengthComputable) {
          setProgress(Math.round((event.loaded / event.total) * 100));
        }
      };

      xhr.onload = () =&amp;gt; (xhr.status === 200 ? resolve() : reject());
      xhr.onerror = () =&amp;gt; reject();
      xhr.send(file);
    })
      .then(() =&amp;gt; setStatus(&quot;done&quot;))
      .catch(() =&amp;gt; setStatus(&quot;error&quot;));
  };

  return (
    &amp;lt;form onSubmit={handleSubmit} className=&quot;flex flex-col gap-3 max-w-sm&quot;&amp;gt;
      &amp;lt;input type=&quot;file&quot; name=&quot;file&quot; accept=&quot;image/*,video/mp4&quot; required /&amp;gt;
      &amp;lt;button
        type=&quot;submit&quot;
        disabled={status === &quot;uploading&quot;}
        className=&quot;rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50&quot;
      &amp;gt;
        {status === &quot;uploading&quot; ? `Uploading... ${progress}%` : &quot;Upload&quot;}
      &amp;lt;/button&amp;gt;
      {status === &quot;done&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-green-600&quot;&amp;gt;Upload complete!&amp;lt;/p&amp;gt;}
      {status === &quot;error&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-destructive&quot;&amp;gt;Upload failed.&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;XMLHttpRequest&lt;/code&gt; instead of &lt;code&gt;fetch&lt;/code&gt;?&lt;/strong&gt; This is a genuinely common question. The &lt;code&gt;fetch&lt;/code&gt; API still doesn't support upload progress tracking in a standard way — &lt;code&gt;xhr.upload.onprogress&lt;/code&gt; does. For a file that could take a while, showing real progress is worth the slightly older API.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting to set the same &lt;code&gt;Content-Type&lt;/code&gt; header on the &lt;code&gt;PUT&lt;/code&gt; request that you signed the URL with. S3 checks that they match — a mismatch fails with a signature error that has nothing to do with your actual credentials, which makes it a confusing bug to track down.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-3-multipart-uploads-for-large-files&quot;&gt;Pattern 3: Multipart Uploads for Large Files&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; instead of uploading one large file as a single request, you split it into chunks (S3 calls these &quot;parts&quot;), upload each part with its own presigned URL, and then tell S3 to stitch them back together.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → Next.js: &quot;start a multipart upload&quot;
Browser → S3: upload part 1, part 2, part 3... (each with its own presigned URL)
Browser → Next.js: &quot;complete the upload&quot; (with the list of parts)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This solves two real problems that plain presigned URLs don't:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reliability&lt;/strong&gt; — if part 4 of 10 fails on a flaky connection, you only retry part 4, not the entire 500MB file&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parallelism&lt;/strong&gt; — multiple parts can upload at the same time instead of one after another&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AWS requires every part except the last to be &lt;strong&gt;at least 5MB&lt;/strong&gt;. This pattern is overkill for a 2MB avatar — it's built for videos, archives, and anything in the tens-to-hundreds-of-MB range.&lt;/p&gt;
&lt;h4 id=&quot;step-1-start-the-multipart-upload&quot;&gt;Step 1: Start the Multipart Upload&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/multipart/start/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { CreateMultipartUploadCommand } from &quot;@aws-sdk/client-s3&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;

export async function POST(request: NextRequest) {
  const { fileName, fileType } = await request.json();

  const key = `large-uploads/${randomUUID()}-${fileName}`;

  const { UploadId } = await s3Client.send(
    new CreateMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: key,
      ContentType: fileType,
    })
  );

  return NextResponse.json({ uploadId: UploadId, key });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells S3 &quot;expect a multipart upload for this key&quot; and gets back an &lt;code&gt;UploadId&lt;/code&gt; — a token that ties every part together as belonging to the same file.&lt;/p&gt;
&lt;h4 id=&quot;step-2-get-a-presigned-url-for-each-part&quot;&gt;Step 2: Get a Presigned URL for Each Part&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/multipart/presign-part/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { UploadPartCommand } from &quot;@aws-sdk/client-s3&quot;;
import { getSignedUrl } from &quot;@aws-sdk/s3-request-presigner&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;

export async function POST(request: NextRequest) {
  const { key, uploadId, partNumber } = await request.json();

  const command = new UploadPartCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: key,
    UploadId: uploadId,
    PartNumber: partNumber,
  });

  const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });

  return NextResponse.json({ url });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each part needs its own presigned URL, tagged with a &lt;code&gt;PartNumber&lt;/code&gt; (1, 2, 3...) — this is how S3 knows the order to reassemble them in later.&lt;/p&gt;
&lt;h4 id=&quot;step-3-complete-the-multipart-upload&quot;&gt;Step 3: Complete the Multipart Upload&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/multipart/complete/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { CompleteMultipartUploadCommand } from &quot;@aws-sdk/client-s3&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;

export async function POST(request: NextRequest) {
  const { key, uploadId, parts } = await request.json();
  // parts: { PartNumber: number; ETag: string }[]

  await s3Client.send(
    new CompleteMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: key,
      UploadId: uploadId,
      MultipartUpload: { Parts: parts },
    })
  );

  return NextResponse.json({ success: true, key });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why does every part need an &lt;code&gt;ETag&lt;/code&gt;?&lt;/strong&gt; Each time S3 accepts a part, it returns an &lt;code&gt;ETag&lt;/code&gt; in the response headers — essentially a fingerprint confirming that part uploaded correctly and intact. When you call &quot;complete,&quot; S3 cross-checks every &lt;code&gt;ETag&lt;/code&gt; you send against what it actually received before stitching the file together. This is exactly why we exposed the &lt;code&gt;ETag&lt;/code&gt; header in the CORS config back in setup.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting to add a cleanup step for &lt;strong&gt;abandoned&lt;/strong&gt; multipart uploads (user closes the tab mid-upload). S3 keeps incomplete parts around and bills you for that storage. Set up an &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html&quot;&gt;S3 Lifecycle rule&lt;/a&gt; to auto-abort incomplete uploads after a day or two.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-4-orchestrate-it-all-from-the-browser&quot;&gt;Step 4: Orchestrate It All From the Browser&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/multipart-upload-form.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;

const CHUNK_SIZE = 6 * 1024 * 1024; // 6MB per part (must be 5MB+)
// S3 requires every part except the last to be at least 5MB. Choosing 6MB provides a small buffer while keeping memory usage reasonable.

export function MultipartUploadForm() {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState&amp;lt;&quot;idle&quot; | &quot;uploading&quot; | &quot;done&quot; | &quot;error&quot;&amp;gt;(&quot;idle&quot;);

  const uploadPart = async (
    url: string,
    chunk: Blob
  ): Promise&amp;lt;string&amp;gt; =&amp;gt; {
    const res = await fetch(url, { method: &quot;PUT&quot;, body: chunk });
    const etag = res.headers.get(&quot;ETag&quot;);
    if (!etag) throw new Error(&quot;Missing ETag from S3 response&quot;);
    return etag;
  };

  const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    const form = e.currentTarget;
    const fileInput = form.elements.namedItem(&quot;file&quot;) as HTMLInputElement;
    const file = fileInput.files?.[0];
    if (!file) return;

    setStatus(&quot;uploading&quot;);
    setProgress(0);

    try {
      // Step 1: start the multipart upload
      const startRes = await fetch(&quot;/api/multipart/start&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ fileName: file.name, fileType: file.type }),
      });
      const { uploadId, key } = await startRes.json();

      // Step 2: split the file into chunks and upload each one
      const totalParts = Math.ceil(file.size / CHUNK_SIZE);
      const completedParts: { PartNumber: number; ETag: string }[] = [];

      for (let partNumber = 1; partNumber &amp;lt;= totalParts; partNumber++) {
        const start = (partNumber - 1) * CHUNK_SIZE;
        const chunk = file.slice(start, start + CHUNK_SIZE);

        const presignRes = await fetch(&quot;/api/multipart/presign-part&quot;, {
          method: &quot;POST&quot;,
          headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
          body: JSON.stringify({ key, uploadId, partNumber }),
        });
        const { url } = await presignRes.json();

        const etag = await uploadPart(url, chunk);
        completedParts.push({ PartNumber: partNumber, ETag: etag });

        setProgress(Math.round((partNumber / totalParts) * 100));
      }

      // Step 3: tell S3 to assemble the parts into the final file
      await fetch(&quot;/api/multipart/complete&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ key, uploadId, parts: completedParts }),
      });

      setStatus(&quot;done&quot;);
    } catch (error) {
      console.error(&quot;Multipart upload failed:&quot;, error);
      setStatus(&quot;error&quot;);
    }
  };

  return (
    &amp;lt;form onSubmit={handleSubmit} className=&quot;flex flex-col gap-3 max-w-sm&quot;&amp;gt;
      &amp;lt;input type=&quot;file&quot; name=&quot;file&quot; required /&amp;gt;
      &amp;lt;button
        type=&quot;submit&quot;
        disabled={status === &quot;uploading&quot;}
        className=&quot;rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50&quot;
      &amp;gt;
        {status === &quot;uploading&quot; ? `Uploading... ${progress}%` : &quot;Upload Large File&quot;}
      &amp;lt;/button&amp;gt;
      {status === &quot;done&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-green-600&quot;&amp;gt;Upload complete!&amp;lt;/p&amp;gt;}
      {status === &quot;error&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-destructive&quot;&amp;gt;Upload failed.&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Walking through the flow:&lt;/strong&gt; we start the upload and get back an &lt;code&gt;uploadId&lt;/code&gt;, then loop through the file in 6MB slices using &lt;code&gt;file.slice()&lt;/code&gt; — this is a native browser API, no library needed. For each slice, we request a fresh presigned URL, &lt;code&gt;PUT&lt;/code&gt; the chunk directly to S3, and hold onto the &lt;code&gt;ETag&lt;/code&gt; we get back. Once every part is uploaded, we send the full list of &lt;code&gt;{ PartNumber, ETag }&lt;/code&gt; pairs to the &quot;complete&quot; endpoint, and S3 assembles them into one file.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; The loop above uploads parts one at a time for clarity. In production, you'd typically run several &lt;code&gt;uploadPart&lt;/code&gt; calls concurrently (e.g. 3–4 at once with &lt;code&gt;Promise.all&lt;/code&gt;) to meaningfully speed up large uploads — just be sure to still track and report progress per part.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;which-pattern-should-you-actually-use--1&quot;&gt;Which Pattern Should You Actually Use?&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Pattern&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Small files (avatars, PDFs under ~5MB) &lt;/td&gt;&lt;td&gt;Server-side proxying&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;You need to validate/process the file server-side before storing it &lt;/td&gt;&lt;td&gt;Server-side proxying&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;User-facing images or short videos, a few MB to ~50MB &lt;/td&gt;&lt;td&gt;Direct-to-S3 presigned URL&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;You want upload progress without server load &lt;/td&gt;&lt;td&gt;Direct-to-S3 presigned URL&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Large files — videos, archives, datasets (50MB+) &lt;/td&gt;&lt;td&gt;Multipart upload&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Uploads over unreliable connections that need retry-per-chunk &lt;/td&gt;&lt;td&gt;Multipart upload&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A common real-world setup actually &lt;strong&gt;combines&lt;/strong&gt; these: use a presigned URL (single or multipart, depending on size) to get the file into S3 quickly, then have S3 trigger a Lambda or your own webhook afterward to run any validation, resizing, or processing you'd otherwise have done during a server-side proxy.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-2&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-to-use-the-aws-sdk-or-can-i-just-use-fetch-with-s3-s-rest-api-directly-&quot;&gt;Do I need to use the AWS SDK, or can I just use fetch with S3's REST API directly?&lt;/h4&gt;
&lt;p&gt;You technically can call S3's REST API directly, but you'd be manually implementing AWS's request-signing algorithm (SigV4) yourself — the SDK handles that correctly and is the standard approach. Stick with &lt;code&gt;@aws-sdk/client-s3&lt;/code&gt; unless you have a very specific reason not to.&lt;/p&gt;
&lt;h4 id=&quot;is-it-safe-to-expose-the-s3-object-key-to-the-browser-&quot;&gt;Is it safe to expose the S3 object key to the browser?&lt;/h4&gt;
&lt;p&gt;Yes — the key is just a file path, not a credential. What actually grants access is the signed portion of the presigned URL, which expires and is scoped to that one action. Just don't put sensitive information (like a user's email) directly in the key name if the bucket could ever become browsable.&lt;/p&gt;
&lt;h4 id=&quot;can-i-use-this-with-the-pages-router-instead-of-app-router-&quot;&gt;Can I use this with the Pages Router instead of App Router?&lt;/h4&gt;
&lt;p&gt;The S3 SDK logic (creating the client, generating presigned URLs) works identically. You'd just swap the Route Handlers for &lt;code&gt;pages/api/*.ts&lt;/code&gt; files using &lt;code&gt;req&lt;/code&gt;/&lt;code&gt;res&lt;/code&gt; instead of &lt;code&gt;NextRequest&lt;/code&gt;/&lt;code&gt;NextResponse&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-if-a-presigned-url-expires-mid-upload-&quot;&gt;What happens if a presigned URL expires mid-upload?&lt;/h4&gt;
&lt;p&gt;For the single presigned URL pattern, a slow upload could in theory outlive a very short expiry — that's why the expiry is a balance: long enough to comfortably start and finish, short enough to limit exposure if leaked. For multipart uploads, each part gets its own fresh URL, so this is naturally less of a concern for large files.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-cloudfront-in-front-of-my-bucket-&quot;&gt;Do I need CloudFront in front of my bucket?&lt;/h4&gt;
&lt;p&gt;Not for uploads — CloudFront is about serving files back out fast, which is a separate concern from getting files in. If you're also displaying these uploaded files to users at scale, adding a CloudFront distribution in front of the bucket is a common next step, but it's optional for the upload flow itself. See my &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;guide on displaying S3 images&lt;/a&gt; for the full breakdown of the public, presigned, and CloudFront display patterns.&lt;/p&gt;
&lt;h4 id=&quot;should-i-use-server-actions-for-uploads-&quot;&gt;Should I use Server Actions for uploads?&lt;/h4&gt;
&lt;p&gt;Server Actions work well for small files, but they still route the file through your server. The same trade-offs apply: body size limits, execution time, and bandwidth costs. For larger uploads, presigned URLs remain the recommended approach.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-2&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;You now have three working, production-grade upload patterns for S3 in Next.js 16 — server-side proxying for small files you need to inspect, presigned URLs for everyday user uploads, and multipart uploads for the big stuff that needs to survive a bad connection. The pattern you reach for isn't really an S3 question — it's a question of how much of that file actually needs to pass through your server, and now you've got the code to implement whichever answer fits.&lt;/p&gt;
&lt;p&gt;From here, the natural next step is &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;displaying these uploaded files back to your users&lt;/a&gt; — covering public URLs, presigned GET links, and CloudFront delivery.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/next-s3-setup&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;continue-learning-2&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;How to Display Images from S3 in Next.js 16 (2026 Guide)&lt;/a&gt; — now that your files are in S3, here's how to show them back to users safely&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/resend-nextjs-16-guide&quot;&gt;How to Setup Resend Email in Next.js 16 (2026 Complete Beginner Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR &amp;amp; Cache Components (2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23910/next-js-16-aws-s3-file-upload-guide-presigned-urls-proxy-multipart</guid>
<pubDate>Sat, 01 Aug 2026 19:00:00 +0000</pubDate>
</item>
<item>
<title>Upload Files to AWS S3 with Spring Boot (Clean Architecture Guide)</title>
<link>https://coderlegion.com/14577/upload-files-to-aws-s3-with-spring-boot-clean-architecture-guide</link>
<description>&lt;p&gt;Uploading files to AWS S3 in Spring Boot is simple.&lt;/p&gt;
&lt;p&gt;Designing it properly for production is where most developers struggle.&lt;/p&gt;
&lt;p&gt;Most implementations end up like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;controllers handling everything&lt;/li&gt;
&lt;li&gt;direct S3 calls everywhere&lt;/li&gt;
&lt;li&gt;no structure&lt;/li&gt;
&lt;li&gt;hard to scale later&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this guide, you’ll learn how to build a clean, scalable file upload system.&lt;/p&gt;
&lt;p&gt;Building file uploads in production is harder than it looks.&lt;/p&gt;
&lt;p&gt;If you want a clean, ready-to-use setup instead of building everything yourself:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://buildbasekit.com/boilerplates/filora-fs-pro/&quot;&gt;https://buildbasekit.com/boilerplates/filora-fs-pro/&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer-1&quot;&gt;Quick Answer&lt;/h3&gt;
&lt;p&gt;The best way to upload files to S3 in Spring Boot:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;separate storage logic into a service layer  &lt;/li&gt;
&lt;li&gt;avoid direct S3 calls in controllers  &lt;/li&gt;
&lt;li&gt;use pre-signed URLs for scalability  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-use-aws-s3&quot;&gt;Why use AWS S3&lt;/h3&gt;
&lt;p&gt;S3 is the standard for file storage in backend systems.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;handles large files and high traffic  &lt;/li&gt;
&lt;li&gt;highly scalable and durable  &lt;/li&gt;
&lt;li&gt;no infrastructure management  &lt;/li&gt;
&lt;li&gt;supports secure access control  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-to-use-s3-vs-local-storage&quot;&gt;When to use S3 vs local storage&lt;/h3&gt;
&lt;p&gt;Use S3 when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;building production systems  &lt;/li&gt;
&lt;li&gt;handling user uploads  &lt;/li&gt;
&lt;li&gt;running multiple instances  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use local storage when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;prototyping  &lt;/li&gt;
&lt;li&gt;small internal tools  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Avoid local storage in distributed systems.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;high-level-architecture&quot;&gt;High-level architecture&lt;/h3&gt;
&lt;p&gt;A clean file upload system should look like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Controller → handles incoming requests  &lt;/li&gt;
&lt;li&gt;Service → processes file logic  &lt;/li&gt;
&lt;li&gt;Storage layer → interacts with S3  &lt;/li&gt;
&lt;li&gt;Database → stores file metadata  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This separation keeps your code maintainable.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;file-metadata-design&quot;&gt;File metadata design&lt;/h3&gt;
&lt;p&gt;Do not just upload files.&lt;/p&gt;
&lt;p&gt;Store metadata like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;file name  &lt;/li&gt;
&lt;li&gt;unique ID  &lt;/li&gt;
&lt;li&gt;S3 object key  &lt;/li&gt;
&lt;li&gt;file size and type  &lt;/li&gt;
&lt;li&gt;upload timestamp  &lt;/li&gt;
&lt;li&gt;user reference  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This enables search, access control, and tracking.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;example-api-design&quot;&gt;Example API design&lt;/h3&gt;
&lt;p&gt;A simple API structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;POST /files → upload file  &lt;/li&gt;
&lt;li&gt;GET /files/{id} → fetch metadata  &lt;/li&gt;
&lt;li&gt;GET /files/{id}/url → get access URL  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;upload-flow&quot;&gt;Upload flow&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;client sends upload request  &lt;/li&gt;
&lt;li&gt;backend validates file  &lt;/li&gt;
&lt;li&gt;file is uploaded to S3  &lt;/li&gt;
&lt;li&gt;metadata is stored in database  &lt;/li&gt;
&lt;li&gt;response returns file reference  &lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;keep-storage-logic-abstract&quot;&gt;Keep storage logic abstract&lt;/h3&gt;
&lt;p&gt;Do not tightly couple your app with S3.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;define a storage interface  &lt;/li&gt;
&lt;li&gt;implement S3 separately  &lt;/li&gt;
&lt;li&gt;allow switching providers later  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This makes your system flexible.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;security-considerations&quot;&gt;Security considerations&lt;/h3&gt;
&lt;p&gt;Always handle security properly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;validate file type and size  &lt;/li&gt;
&lt;li&gt;avoid exposing S3 bucket directly  &lt;/li&gt;
&lt;li&gt;manage credentials securely  &lt;/li&gt;
&lt;li&gt;use pre-signed URLs  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pre-signed-urls-important-&quot;&gt;Pre-signed URLs (important)&lt;/h3&gt;
&lt;p&gt;Better approach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;backend generates upload URL  &lt;/li&gt;
&lt;li&gt;client uploads directly to S3  &lt;/li&gt;
&lt;li&gt;backend stores metadata  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;reduces server load  &lt;/li&gt;
&lt;li&gt;improves scalability  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;common-mistakes&quot;&gt;Common mistakes&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;calling S3 directly from controller  &lt;/li&gt;
&lt;li&gt;hardcoding credentials  &lt;/li&gt;
&lt;li&gt;skipping metadata storage  &lt;/li&gt;
&lt;li&gt;mixing upload and access logic  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;Uploading files to S3 is easy.&lt;/p&gt;
&lt;p&gt;Designing a clean system is what matters long term.&lt;/p&gt;
&lt;p&gt;If you skip structure now, it will become painful later.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;skip-the-setup&quot;&gt;Skip the setup&lt;/h3&gt;
&lt;p&gt;If you're building anything serious, don't spend days structuring this.&lt;/p&gt;
&lt;p&gt;Use a production-ready backend instead:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://buildbasekit.com/boilerplates/filora-fs-pro/&quot;&gt;https://buildbasekit.com/boilerplates/filora-fs-pro/&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;clean architecture  &lt;/li&gt;
&lt;li&gt;S3 + pre-signed URLs  &lt;/li&gt;
&lt;li&gt;ready APIs  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Build faster. Focus on real features.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14577/upload-files-to-aws-s3-with-spring-boot-clean-architecture-guide</guid>
<pubDate>Sun, 12 Apr 2026 16:42:50 +0000</pubDate>
</item>
<item>
<title>Small AWS Updates, Big Impact: S3 Namespace Shift and Smarter Security Scanning</title>
<link>https://coderlegion.com/13194/small-aws-updates-big-impact-s3-namespace-shift-and-smarter-security-scanning</link>
<description>&lt;h2 id=&quot;aws-security-updates-s3-namespace-evolution-inspector-agentless-scanning-and-windows-kb-based-findings&quot;&gt;AWS Security Updates: S3 Namespace Evolution, Inspector Agentless Scanning, and Windows KB-Based Findings&lt;/h2&gt;
&lt;p&gt;AWS recently introduced a few important updates across Amazon S3 and Amazon Inspector. These changes are subtle but have real implications for how we design storage and manage security at scale.&lt;/p&gt;
&lt;p&gt;Here’s what stands out:&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-amazon-s3-account-level-regional-namespaces-big-shift-in-naming-&quot;&gt;1. Amazon S3: Account-Level Regional Namespaces (Big Shift in Naming)&lt;/h3&gt;
&lt;p&gt;AWS has introduced &lt;strong&gt;account-level regional namespaces for S3 general-purpose buckets&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This is a significant evolution from the traditional &lt;strong&gt;global bucket namespace model&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;What’s new:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You can now create buckets within an &lt;strong&gt;account-specific regional namespace&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;This namespace is &lt;strong&gt;scoped to your AWS account&lt;/strong&gt;, instead of competing globally&lt;/li&gt;
&lt;li&gt;Available across multiple AWS regions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why this matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Eliminates friction of globally unique bucket naming&lt;/li&gt;
&lt;li&gt;Simplifies automation (Terraform / CI/CD no longer needs random suffix hacks)&lt;/li&gt;
&lt;li&gt;Better alignment with &lt;strong&gt;multi-account and multi-region architectures&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In simple terms:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;S3 is moving from &lt;em&gt;global naming constraints&lt;/em&gt; → &lt;em&gt;account-scoped flexibility&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is a foundational change, especially for large organizations managing multiple environments. &lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-amazon-inspector-expanded-agentless-ec2-scanning&quot;&gt;2. Amazon Inspector: Expanded Agentless EC2 Scanning&lt;/h3&gt;
&lt;p&gt;Amazon Inspector now expands &lt;strong&gt;agentless EC2 scanning&lt;/strong&gt;, increasing coverage without operational overhead.&lt;/p&gt;
&lt;p&gt;What’s improved:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Broader vulnerability detection across:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OS packages&lt;/li&gt;
&lt;li&gt;Application stacks (Python, Ruby, Apache, WordPress, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;No need to install or manage agents&lt;/li&gt;
&lt;li&gt;Works using &lt;strong&gt;EBS snapshot-based scanning&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why this matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Faster adoption across large fleets&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ideal for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Legacy systems&lt;/li&gt;
&lt;li&gt;Restricted or hardened environments&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Reduces dependency on SSM agent management&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This significantly improves &lt;strong&gt;security coverage with minimal effort&lt;/strong&gt;. &lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-windows-kb-based-findings-better-vulnerability-context-&quot;&gt;3. Windows KB-Based Findings (Better Vulnerability Context)&lt;/h3&gt;
&lt;p&gt;AWS also introduced &lt;strong&gt;Windows KB-based findings&lt;/strong&gt; in Amazon Inspector.&lt;/p&gt;
&lt;p&gt;What changed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Instead of multiple CVEs per vulnerability → &lt;strong&gt;grouped into a single KB finding&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Each finding includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Highest CVSS score&lt;/li&gt;
&lt;li&gt;EPSS score&lt;/li&gt;
&lt;li&gt;Exploit availability&lt;/li&gt;
&lt;li&gt;Direct link to Microsoft KB article&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why this matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reduces noise in vulnerability reports&lt;/li&gt;
&lt;li&gt;Makes remediation clearer and actionable&lt;/li&gt;
&lt;li&gt;Aligns findings with how Windows patching actually works&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;One patch → one finding → clearer action&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is a big usability improvement for security teams managing Windows workloads. &lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;These updates highlight a clear direction from AWS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Simplifying &lt;strong&gt;infrastructure design&lt;/strong&gt; (S3 namespaces)&lt;/li&gt;
&lt;li&gt;Reducing &lt;strong&gt;operational overhead&lt;/strong&gt; (agentless scanning)&lt;/li&gt;
&lt;li&gt;Improving &lt;strong&gt;security clarity and actionability&lt;/strong&gt; (KB-based findings)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Individually, these may look small.&lt;br&gt;
Together, they remove friction across cloud operations and security workflows.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-recommend&quot;&gt;What I Recommend&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Revisit your &lt;strong&gt;S3 naming strategy&lt;/strong&gt; (especially for new workloads)&lt;/li&gt;
&lt;li&gt;Enable or validate &lt;strong&gt;Inspector agentless scanning&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Update your &lt;strong&gt;vulnerability management workflows&lt;/strong&gt; for KB-based findings&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;Cloud is evolving — and the small updates are often the ones that change how we build at scale.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13194/small-aws-updates-big-impact-s3-namespace-shift-and-smarter-security-scanning</guid>
<pubDate>Thu, 19 Mar 2026 12:23:53 +0000</pubDate>
</item>
<item>
<title>Syncing AWS S3 Storage bucket objects to LocalStack S3 Storage bucket</title>
<link>https://coderlegion.com/1761/syncing-aws-s3-storage-bucket-objects-to-localstack-s3-storage-bucket</link>
<description>&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;LocalStack is a cloud service emulator that runs in a single container on your laptop or in your CI environment. With LocalStack, you can run your AWS applications or Lambdas entirely on your local machine without connecting to a remote cloud provider.&lt;/p&gt;
&lt;h3 id=&quot;installation&quot;&gt;Installation&lt;/h3&gt;
&lt;p&gt;In this tutorial, we will install LocalStack CLI on our x86-64 Linux machine, for ARM64 kindly check the &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.localstack.cloud/getting-started/installation/&quot;&gt;official documentation&lt;/a&gt;. The CLI starts and manages the LocalStack Docker container. &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;-- Download the pre-built binary --
curl --output localstack-cli-4.2.0-linux-amd64-onefile.tar.gz \
    --location https://github.com/localstack/localstack-cli/releases/download/v4.2.0/localstack-cli-4.2.0-linux-amd64-onefile.tar.gz

-- Extract the LocalStack CLI from the terminal --
sudo tar xvzf localstack-cli-4.2.0-linux-*-onefile.tar.gz -C /usr/local/bin

-- Verify installed version --
localstack --version
4.2.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;configuring-and-starting-localstack&quot;&gt;Configuring and Starting LocalStack&lt;/h3&gt;
&lt;p&gt;We need an API key from LocalStack to configure LocalStack on our local machine. Head over to &lt;a rel=&quot;nofollow&quot; href=&quot;https://app.localstack.cloud/sign-up&quot;&gt;LocalStack&lt;/a&gt; and signup. Within your LocalStack dashboard, go to &lt;code&gt;WorkSpace&lt;/code&gt; → &lt;code&gt;Auth Tokens&lt;/code&gt; and copy the personal auth token provided. This personal auth token will be used to activate the LocalStack pro hobby subscription which grants use access to all of LocalStack’s features such as the Resource Browser which will use in this tutorial. Now add the auth token to your local stack CLI and start LocalStack:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;localstack auth set-token &amp;lt;YOUR_AUTH_TOKEN&amp;gt;
localstack start

     __                     _______ __             __
    / /   ____  _________ _/ / ___// /_____ ______/ /__
   / /   / __ \/ ___/ __ `/ /\__ \/ __/ __ `/ ___/ //_/
  / /___/ /_/ / /__/ /_/ / /___/ / /_/ /_/ / /__/ ,&amp;lt;
 /_____/\____/\___/\__,_/_//____/\__/\__,_/\___/_/|_|

-- LocalStack CLI: 4.2.0 --
-- Profile: default --
-- App: https://app.localstack.cloud --

[12:31:41] starting LocalStack in Docker mode ????                                                       localstack.py:512
2025-03-27T12:31:41.282  INFO --- [  MainThread] l.p.c.b.licensingv2        : Successfully activated cached license &amp;lt;license-id&amp;gt;:hobby from /home/user/.cache/localstack-cli/license.json ????✅
──────────────────────────────────── LocalStack Runtime Log (press CTRL-C to quit) ─────────────────────────────────────
LocalStack version: 4.2.1.dev87
LocalStack build date: 2025-03-26
LocalStack build git hash: c6fc9b010
2025-03-27T09:31:48.092  INFO --- [  MainThread] l.p.c.b.licensingv2        : Successfully activated cached license &amp;lt;license-id&amp;gt;:hobby from /etc/localstack/conf.d/license.json ????✅
2025-03-27T09:31:52.329  INFO --- [  MainThread] l.p.c.extensions.platform  : loaded 0 extensions
Ready.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;installing-aws-cli-and-localstack-s-aws-cli&quot;&gt;Installing AWS CLI and LocalStack’s AWS CLI&lt;/h3&gt;
&lt;p&gt;We now need to have both the AWS official CLI and LocalStack’s AWS CLI which is a thin wrapper of the AWS official CLI for LocalStack. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html&quot;&gt;AWS CLI installation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;-- Verify installation of the official AWS CLI --
aws --version
aws-cli/2.25.4 Python/3.12.9 Linux/5.15.167.4-microsoft-standard-WSL2 exe/x86_64.ubuntu.24

-- Install LocalStack's AWS CLI --
pip install awscli-local
awslocal --version
aws-cli/2.25.4 Python/3.12.9 Linux/5.15.167.4-microsoft-standard-WSL2 exe/x86_64.ubuntu.24
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;creating-s3-storage-bucket-in-localstack&quot;&gt;Creating S3 Storage Bucket in LocalStack&lt;/h3&gt;
&lt;p&gt;Before syncing the buckets, we need to create an S3 storage bucket within LocalStack in our local machine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;-- Create bucket --
awslocal s3api create-bucket \
  --acl public-read \
  --bucket test-s3-bucket \
  --object-ownership BucketOwnerPreferred \
  --region us-east-2 \
  --create-bucket-configuration '{&quot;LocationConstraint&quot;: &quot;us-east-2&quot;}' 

-- Sample output with bucket name set to test-s3-bucket --
{
    &quot;Location&quot;: &quot;http://test-s3-bucket.s3.localhost.localstack.cloud:4566/&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;syncing-s3-storage-buckets&quot;&gt;Syncing S3 Storage Buckets&lt;/h2&gt;
&lt;p&gt;In this tutorial, we will focus on two keys methods for syncing our AWS S3 storage bucket with our LocalStack S3 storage bucket within our local machine. &lt;/p&gt;
&lt;h3 id=&quot;method-1-sequential-sync&quot;&gt;Method 1: Sequential sync&lt;/h3&gt;
&lt;p&gt;The sequential sync approach is whereby we sync the bucket to a local temp folder in our local machine then sync our LocalStack S3 storage bucket with data in the local temp folder. &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;-- Syncing bucket data to local temp folder --
aws s3 sync s3://&amp;lt;source-bucket i.e test-s3-bucket&amp;gt; ./local-folder --region &amp;lt;region i.e us-east-2&amp;gt;

-- Syncing local folder to LocalStack S3 bucket --
aws s3 sync ./local-folder s3://&amp;lt;destination-bucket i.e test-s3-bucket&amp;gt; --endpoint-url http://localhost:4566 # By default, LocalStack runs on port 4566. Make sure no service is running on this port otherwise LocalStack won't start

-- Verify upload --
awslocal s3 ls s3://test-s3-bucket
2025-03-27 13:04:21     165892 test-s3-file.jpeg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Method 2: &lt;strong&gt;Direct Sync (Without Local Storage)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The direct sync approach avoids syncing the bucket data to local storage first but rather syncs the bucket data directly to the LocalStack S3 storage bucket. In this tutorial, we will use a bash script to sync the buckets but you can create a python script and use boto3 and LocalStack’s &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.localstack.cloud/user-guide/integrations/sdks/python/&quot;&gt;python client&lt;/a&gt; to sync the buckets. &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;aws s3 ls s3://&amp;lt;aws-s3-bucket&amp;gt; --recursive | awk '{print $4}' | \
xargs -P 10 -I {} sh -c 'aws s3 cp --debug &quot;s3://&amp;lt;aws-test-s3-bucket&amp;gt;/{}&quot; - | awslocal s3 cp - &quot;s3://&amp;lt;localstack-test-s3-bucket&amp;gt;/{}&quot; --debug'

-- Sample output in terminal running localstack --
2025-03-27T10:12:24.654  INFO --- [et.reactor-0] localstack.request.aws     : AWS s3.PutObject =&amp;gt; 200
2025-03-27T10:12:27.443  INFO --- [et.reactor-0] localstack.request.aws     : AWS s3.PutObject =&amp;gt; 200
2025-03-27T10:12:37.219  INFO --- [et.reactor-0] localstack.request.aws     : AWS s3.PutObject =&amp;gt; 200
2025-03-27T10:12:41.503  INFO --- [et.reactor-0] localstack.request.aws     : AWS s3.PutObject =&amp;gt; 200
2025-03-27T10:12:45.346  INFO --- [et.reactor-0] localstack.request.aws     : AWS s3.PutObject =&amp;gt; 200
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the upload is complete, you can head to your LocalStack dashboard navigate to local instance, go to resource browser, search for S3, choose bucket and browse your files or folders in your bucket.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j7fb52st5qlh4ygik3tr.JPG&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ve7r6dge9jgr725i13c3.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The local storage first approach is more performant as compared to the direct approach since the direct approach relies on &lt;code&gt;xargs&lt;/code&gt; to achieve parallel transfers using sub-processes. One can spawn multiple separate sub processes using &lt;code&gt;xargs -P N&lt;/code&gt; when &lt;code&gt;N&lt;/code&gt; is the number of sub processes required. The sub process parallelism is great for S3 sync as this is an I/O bound task. In terms of speed, using &lt;code&gt;xargs&lt;/code&gt; even with tuned parallelism can be slow. If you are looking to speed up syncs, you can use tools such as &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/peak/s5cmd&quot;&gt;&lt;code&gt;s5cmd&lt;/code&gt;&lt;/a&gt; which supports true multithreading. I will sure do a tutorial on &lt;code&gt;s5cmd&lt;/code&gt; and do a benchmark to check how it compares to using &lt;code&gt;xargs&lt;/code&gt; in parallel mode. The approach in this tutorial was not created with the context of large S3 storage buckets. If you have a tutorial on this please share. Thanks again for reaching to the end of this tutorial. Cheers!&lt;/p&gt;
</description>
<category>Automation</category>
<guid isPermaLink="true">https://coderlegion.com/1761/syncing-aws-s3-storage-bucket-objects-to-localstack-s3-storage-bucket</guid>
<pubDate>Tue, 01 Apr 2025 12:56:47 +0000</pubDate>
</item>
<item>
<title>Mastering Amazon S3 Replication: Same-Region vs. Cross-Region Replication -</title>
<link>https://coderlegion.com/1033/mastering-amazon-s3-replication-same-region-vs-cross-region-replication</link>
<description>&lt;p&gt;Amazon Simple Storage Service (S3) is a powerhouse for cloud storage, offering unmatched scalability, durability, and security. Among its many features, S3 Replication plays a vital role in ensuring data availability, resilience, and compliance. AWS provides two types of replication mechanisms: Same-Region Replication (SRR) and Cross-Region Replication (CRR). While both serve to replicate objects, they cater to different business needs. Let’s dive deep into their differences, use cases, and best practices.&lt;/p&gt;
&lt;h2 id=&quot;-what-is-amazon-s3-replication-&quot;&gt;&lt;strong&gt; What is Amazon S3 Replication?&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;S3 Replication is an AWS feature that automatically replicates objects from one S3 bucket to another. The replication can occur within the same AWS region (SRR) or across different regions (CRR). This ensures redundancy, availability, and compliance with organizational policies.&lt;/p&gt;
&lt;p&gt;AWS S3 Replication requires enabling Versioning on both the source and destination buckets. Once set up, it replicates newly uploaded objects asynchronously while preserving metadata, object tags, and Access Control Lists (ACLs), depending on your configurations.&lt;/p&gt;
&lt;h2 id=&quot;-same-region-replication-srr-&quot;&gt;&lt;strong&gt; Same-Region Replication (SRR)&lt;/strong&gt;&lt;/h2&gt;
&lt;h3 id=&quot;-what-is-srr-&quot;&gt;✅ What is SRR?&lt;/h3&gt;
&lt;p&gt;SRR allows replication of S3 objects within the same AWS region. This is useful for organizations that need multiple copies of data in the same geography but within different buckets.&lt;/p&gt;
&lt;h3 id=&quot;use-cases&quot;&gt;Use Cases&lt;/h3&gt;
&lt;p&gt;Compliance &amp;amp; Governance: Maintain multiple copies of data within a region to meet internal compliance regulations.&lt;/p&gt;
&lt;p&gt;Data Tiering &amp;amp; Organization: Separate different teams' data while keeping it in the same region for low latency.&lt;/p&gt;
&lt;p&gt;Backup &amp;amp; Disaster Recovery: Keep replicated copies in a different bucket to recover from accidental deletions or corruption.&lt;/p&gt;
&lt;p&gt;Logging &amp;amp; Analytics: Send logs to a centralized bucket for analysis without moving them across regions.&lt;/p&gt;
&lt;h3 id=&quot;-benefits&quot;&gt;⚡ Benefits&lt;/h3&gt;
&lt;p&gt;Low Latency: Since data is within the same region, replication is faster.&lt;/p&gt;
&lt;p&gt;Cost-Effective: No inter-region data transfer costs.&lt;/p&gt;
&lt;p&gt;Better Access Control: Different teams can access separate replicated buckets without modifying the source bucket’s policies.&lt;/p&gt;
&lt;h3 id=&quot;-considerations&quot;&gt;⚠️ Considerations&lt;/h3&gt;
&lt;p&gt;SRR does not protect against regional failures; for true disaster recovery, CRR is needed.&lt;/p&gt;
&lt;p&gt;There might be some added storage costs depending on data volume.&lt;/p&gt;
&lt;h2 id=&quot;-cross-region-replication-crr-&quot;&gt;&lt;strong&gt; Cross-Region Replication (CRR)&lt;/strong&gt;&lt;/h2&gt;
&lt;h3 id=&quot;-what-is-crr-&quot;&gt;✅ What is CRR?&lt;/h3&gt;
&lt;p&gt;CRR replicates objects across different AWS regions, providing geographical redundancy and enhanced disaster recovery.&lt;/p&gt;
&lt;h3 id=&quot;use-cases-1&quot;&gt;Use Cases&lt;/h3&gt;
&lt;p&gt;Disaster Recovery: Store copies of critical data in another region to protect against region-wide outages.&lt;/p&gt;
&lt;p&gt;Compliance &amp;amp; Regulations: Meet legal requirements for keeping data in multiple geographic locations.&lt;/p&gt;
&lt;p&gt;Global Content Distribution: Improve performance by serving data closer to end users in different regions.&lt;/p&gt;
&lt;p&gt;High Availability &amp;amp; Fault Tolerance: Maintain business continuity in case of failures in one region.&lt;/p&gt;
&lt;h3 id=&quot;-benefits-1&quot;&gt;⚡ Benefits&lt;/h3&gt;
&lt;p&gt;Resilient Disaster Recovery: Data is available in another region in case of primary region failure.&lt;/p&gt;
&lt;p&gt;Reduced Latency for Global Users: Store data closer to international customers to reduce access time.&lt;/p&gt;
&lt;p&gt;Improved Business Continuity: Ensures uninterrupted service availability.&lt;/p&gt;
&lt;h3 id=&quot;-considerations-1&quot;&gt;⚠️ Considerations&lt;/h3&gt;
&lt;p&gt;Higher Costs: Inter-region data transfer fees apply.&lt;/p&gt;
&lt;p&gt;Increased Latency: Replication takes longer due to network distance.&lt;/p&gt;
&lt;p&gt;Regulatory Compliance: Ensure cross-border data transfers align with local regulations.&lt;/p&gt;
&lt;h2 id=&quot;-setting-up-s3-replication-srr-amp-crr-&quot;&gt;️ Setting Up S3 Replication (SRR &amp;amp; CRR)&lt;/h2&gt;
&lt;p&gt;To enable replication:&lt;/p&gt;
&lt;p&gt;Enable versioning for both source and destination buckets.&lt;/p&gt;
&lt;p&gt;Set up IAM permissions for replication.&lt;/p&gt;
&lt;p&gt;Create a Replication Rule specifying:&lt;/p&gt;
&lt;p&gt;Source and destination buckets&lt;/p&gt;
&lt;p&gt;Filter conditions (prefix, object tags, etc.)&lt;/p&gt;
&lt;p&gt;Replication options (SRR or CRR)&lt;/p&gt;
&lt;p&gt;Enable S3 Replication Time Control (RTC) for predictable replication times (optional).&lt;/p&gt;
&lt;p&gt;Monitor replication progress using AWS CloudWatch and S3 Replication Metrics.&lt;/p&gt;
&lt;h2 id=&quot;srr-vs-crr-which-one-should-you-choose-&quot;&gt;SRR vs. CRR: Which One Should You Choose?&lt;/h2&gt;
&lt;p&gt;FeatureSame-Region Replication (SRR)Cross-Region Replication (CRR)&lt;br&gt;
Data Transfer CostLower (No inter-region cost)Higher (Inter-region transfer fees)&lt;br&gt;
LatencyLow (Faster replication)Higher (Depends on region distance)&lt;br&gt;
Disaster RecoveryNo protection from regional failuresHigh protection from region-wide failures&lt;br&gt;
ComplianceUseful for internal regulationsRequired for international regulations&lt;br&gt;
Global AccessLimited to one regionEnables worldwide data access&lt;/p&gt;
&lt;h2 id=&quot;best-practices-for-s3-replication&quot;&gt;Best Practices for S3 Replication&lt;/h2&gt;
&lt;p&gt;✅ Use Lifecycle Policies: Delete older versions or move replicated data to S3 Glacier for cost efficiency. &lt;br&gt;
✅ Monitor with CloudWatch: Keep track of replication success rates and failures. &lt;br&gt;
✅ Use Replication Time Control (RTC): Ensure predictable replication times. &lt;br&gt;
✅ Encrypt Replicated Data: Enable AWS KMS encryption to secure sensitive information. &lt;br&gt;
✅ Optimize Costs: Use Intelligent-Tiering for replicated objects to reduce expenses.&lt;/p&gt;
&lt;h2 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Both Same-Region Replication (SRR) and Cross-Region Replication (CRR) are powerful features in AWS S3 that serve different business needs. SRR is ideal for compliance, backups, and data segregation, while CRR is essential for disaster recovery and global distribution. Choosing the right replication strategy depends on your use case, performance requirements, and budget.&lt;/p&gt;
&lt;p&gt;By implementing these best practices, you can ensure optimal data availability, security, and resilience in your AWS cloud environment.&lt;/p&gt;
&lt;p&gt; Ready to enhance your AWS storage strategy? Implement S3 replication today!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/1033/mastering-amazon-s3-replication-same-region-vs-cross-region-replication</guid>
<pubDate>Sun, 02 Feb 2025 09:34:30 +0000</pubDate>
</item>
<item>
<title>AWS S3 Access Points in 2025 using CLI</title>
<link>https://coderlegion.com/712/aws-s3-access-points-in-2025-using-cli</link>
<description>&lt;p&gt;Hello, Machas!&lt;/p&gt;
&lt;p&gt;Today, I’m here to demystify S3 Access Points. No promises of turning you into a superhero, but by the end of this blog, you’ll at least upgrade from a curious bystander to the protagonist of your own AWS journey. Let’s dive in and make you the star of this S3 saga!. &lt;/p&gt;
&lt;p&gt;I hope you might have already known what are bucket polices. If you don’t know I just give you a quick recap: bucket policies are used to restrict/allow access to any user/group/role to access bucket and its objects.&lt;/p&gt;
&lt;h3 id=&quot;use-of-access-points&quot;&gt;Use of Access points&lt;/h3&gt;
&lt;p&gt;Lets consider a scenario today if you want to give read permissions to some objects for a user , you will use simply bucket policy to do that .&lt;/p&gt;
&lt;p&gt;Tomorrow if you want to give some other permissions to a role you will again use the same bucket policy.&lt;/p&gt;
&lt;p&gt;Other day when you want to give permissions to a group if you keep on using this bucket policy this will grow and it becomes hard to read .&lt;br&gt;
 &lt;img src=&quot;https://github.com/user-attachments/assets/4f25ccd4-2ab2-4c34-8a38-4a935531d6a8&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;That’s where access points comes in rescue , instead of giving permissions to every one in one bucket policy you will create individual access points &lt;br&gt;
for each one and give permissions to these access points and all these access points are attached to the bucket.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/user-attachments/assets/bdd3cc05-c734-4c8b-b7f6-9eff580047bb&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;In this way bucket policy wont grow .&lt;/p&gt;
&lt;p&gt;Now its time to get our hands dirty .&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/user-attachments/assets/1f33ad9c-4e7a-42d5-939e-bfebb1915461&quot; alt=&quot;StickyHandsGIF (2)&quot;&gt;&lt;/p&gt;
&lt;p&gt;Let’s consider a scenario where you need to allow access to the objects in your bucket only through access points , &lt;br&gt;
and also grant access to a specific user in another account . Sounds simple, right?&quot;&lt;/p&gt;
&lt;h3 id=&quot;naam-chota-hai-lekin-sound-bada-hai&quot;&gt;NAAM CHOTA HAI LEKIN SOUND BADA HAI&lt;/h3&gt;
&lt;h3 id=&quot;1-firstly-let-s-create-access-point-&quot;&gt;1. Firstly let’s create access point.&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt; 

    aws s3control create-access-point --account-id 9848032919 --name my-access-point \
    --bucket my-bucket

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now this will create access point its network origin is internet if you want to create VPC as network origin then No internet access. &lt;br&gt;
Requests are made over a specified VPC only. This will create access point and is attached to the bucket.&lt;/p&gt;
&lt;p&gt;Grab the access point arn.&lt;/p&gt;
&lt;h3 id=&quot;2-next-let-s-grant-permissions-to-this-access-point&quot;&gt;2.  Next let’s grant permissions to this access point&lt;/h3&gt;
&lt;p&gt;I will now grant user KGF to access the bucket and its objects its simple!!! only if you know already know how to write bucket policy.&lt;br&gt;
That’s why have a quick glance at my above attached blog.&lt;/p&gt;
&lt;p&gt;Access point policy is similar to bucket policy.&lt;/p&gt;
&lt;p&gt;Lets grant KGF user, get object and list bucket permissions , in the access point policy Resource is arn of the access point .&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// access-policy.json

    {
        &quot;Version&quot;: &quot;2012-10-17&quot;,
        &quot;Statement&quot;: [
          {
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: { &quot;AWS&quot;: &quot;arn:aws:iam::9848032919:user/KGF&quot; },
            &quot;Action&quot;: [
              &quot;s3:GetObject&quot;,
              &quot;s3:ListBucket&quot; 
            ],
            &quot;Resource&quot;: [
              &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point&quot;,
              &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point/object/*&quot;
            ]
          }
        ]
      }

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now KGF user will be having get object and list bucket permission on the bucket to which this access point was attached.&lt;/p&gt;
&lt;h3 id=&quot;3-attach-the-permissions-to-access-point&quot;&gt;3. Attach the permissions to access point&lt;/h3&gt;
&lt;p&gt;Now attach this access point policy to the access point.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
    aws s3control put-access-point-policy --account-id 9848032919 --name my-access-point \
     --policy file://access-policy.json

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now if KGF user tries to access the bucket using …&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
    aws s3 ls my-bucket

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You will get error as KGF is fresh user this user doesn’t have any IAM permissions to access the object.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/user-attachments/assets/b0f99bde-2c42-4b0f-b314-0faba5c854df&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;If KGF user has IAM permissions then this user will be able to . But we are interested in accessing the objects in the bucket using access point .&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/user-attachments/assets/899de710-b7ff-4585-8397-a7d13fb4d31f&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;Now also we got same error .&lt;/p&gt;
&lt;h3 id=&quot;4-access-point-based-bucket-permissions&quot;&gt;4. Access Point-Based Bucket Permissions&lt;/h3&gt;
&lt;p&gt;Now we have to configure bucket policy such that this will receive permissions from access point policy on the bucket/objects.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
    // bucket-policy.json
    {
      &quot;Version&quot;: &quot;2012-10-17&quot;,
      &quot;Statement&quot;: [
        {
          &quot;Effect&quot;: &quot;Allow&quot;,
          &quot;Principal&quot;: &quot;*&quot;,
          &quot;Action&quot;: &quot;s3:*&quot;,
          &quot;Resource&quot;: [
            &quot;arn:aws:s3:::my-bucket&quot;,
            &quot;arn:aws:s3:::my-bucket/*&quot;
          ],
          &quot;Condition&quot;: {
            &quot;StringEquals&quot;: {
              &quot;s3:DataAccessPointArn&quot;: &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point&quot;
            }
          }
        }
      ]
    }

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is one condition called DataAccessPointArn which identifies that access is coming from which accesspoint.&lt;/p&gt;
&lt;p&gt;Now this policy allows permissions from accesspoint.&lt;/p&gt;
&lt;p&gt;Uploading this bucket policy ..&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; 

    aws s3api put-bucket-policy --bucket my-bucket --policy file://bucket-policy.json

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now If I try to access the objects again as a KGF user …&lt;br&gt;
&lt;img src=&quot;https://github.com/user-attachments/assets/1d96f85e-43aa-4e0a-8e65-66be97b6a073&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;I got the result ..&lt;/p&gt;
&lt;p&gt;Now that we have allowed KGF user using access point , now our mini goal is to allow users to access the bucket/objects only via access point.&lt;/p&gt;
&lt;h3 id=&quot;5-allowing-traffic-only-through-access-point-&quot;&gt;5. Allowing traffic only through access point.&lt;/h3&gt;
&lt;p&gt;As you guessed we have to change bucket policy to do that.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
    //bucket-policy.json
    {
      &quot;Version&quot;: &quot;2012-10-17&quot;,
      &quot;Statement&quot;: [
        {
          &quot;Effect&quot;: &quot;Deny&quot;,
          &quot;Principal&quot;: &quot;*&quot;,
          &quot;Action&quot;: &quot;s3:*&quot;,
          &quot;Resource&quot;: [
            &quot;arn:aws:s3:::my-bucket&quot;,
            &quot;arn:aws:s3:::my-bucket/*&quot;
          ],
          &quot;Condition&quot;: {
            &quot;StringNotEquals&quot;: {
              &quot;s3:DataAccessPointArn&quot;: &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point&quot;
            }
          }
        },
        {
          &quot;Effect&quot;: &quot;Allow&quot;,
          &quot;Principal&quot;: &quot;*&quot;,
          &quot;Action&quot;: &quot;s3:*&quot;,
           &quot;Resource&quot;: [
            &quot;arn:aws:s3:::my-bucket&quot;,
            &quot;arn:aws:s3:::my-bucket/*&quot;
          ],
          &quot;Condition&quot;: {
            &quot;StringEquals&quot;: {
              &quot;s3:DataAccessPointArn&quot;: &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point&quot;
            }
          }
        }
      ]
    }

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Don’t get panic I am here to explain this.&lt;/p&gt;
&lt;p&gt;1st rule is to deny every traffic unless it is made through access point .&lt;/p&gt;
&lt;p&gt;2nd rule is to allow traffic from access point .&lt;/p&gt;
&lt;p&gt;Seems Confusing right ? Deny rules has more precedence than allow rules, so deny rule here in bucket policy ,denies every one even access point permissions and over writes the access point policy , and we are again allowing permissions to flow from access point .&lt;/p&gt;
&lt;p&gt;Uploading this bucket policy ..&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
     aws s3api put-bucket-policy --bucket my-bucket --policy file://bucket-policy.json

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When I access as KGF user …&lt;br&gt;
&lt;img src=&quot;https://github.com/user-attachments/assets/f7855535-8087-456b-b6cb-5d44756d74b7&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;This worked ..&lt;/p&gt;
&lt;p&gt;OK but lets try accessing the objects in the bucket using normal S3 URI instead of access point .&lt;br&gt;
&lt;img src=&quot;https://github.com/user-attachments/assets/34490cc6-3946-4669-a6d8-8be1f0c179f9&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;I got explicit deny error and that’s worked hurray!!!&lt;/p&gt;
&lt;h3 id=&quot;6-cross-account-access&quot;&gt;6.Cross Account Access&lt;/h3&gt;
&lt;p&gt;Now we have almost completed our requirement, next step is we have to allow user from the other account to access objects in the bucket in our account .&lt;/p&gt;
&lt;p&gt;If you give arn of the user in the other account as a Principal in the access point policy, only this is not enough to do so. The User in the other account must have IAM permissions to access the s3 bucket as well as this access point in our account..&lt;/p&gt;
&lt;p&gt;IAM policy to access S3 in other account :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
    {
        &quot;Version&quot;: &quot;2012-10-17&quot;,
        &quot;Statement&quot;: [
            {
                &quot;Sid&quot;: &quot;Statement1&quot;,
                &quot;Effect&quot;: &quot;Allow&quot;,
                &quot;Action&quot;: [
                    &quot;s3:ListBucket&quot;,
                    &quot;s3:GetObject&quot;
                ],
                &quot;Resource&quot;: [
                    &quot;arn:aws:s3:::my-bucket&quot;,
                    &quot;arn:aws:s3:::my-bucket/*&quot;
                ]
            }
        ]
    }

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As the bucket names are unique that’s why no Account IDs in resource .&lt;/p&gt;
&lt;p&gt;IAM policy to access the access point&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; 

    {
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
    {
    &quot;Sid&quot;: &quot;Statement1&quot;,
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Action&quot;: [
    &quot;s3:*&quot;
    ],
     &quot;Resource&quot;: [
                  &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point&quot;,
                  &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point/object/*&quot;    
                ]
    }
    ]
    }

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now if the user has these 2 IAM Policies ,then after adding user arn as the Principal in the access point policy.&lt;/p&gt;
&lt;p&gt;I have created user named Ali in the other account and also Ali has 2 IAM policies set.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
    {
        &quot;Version&quot;: &quot;2012-10-17&quot;,
        &quot;Statement&quot;: [
          {
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: { &quot;AWS&quot;: 
                         [&quot;arn:aws:iam::9848032919:user/KGF&quot;,
                           &quot;arn:aws:iam::9848022338:user/Ali&quot; ] 
                             },
            &quot;Action&quot;: [
              &quot;s3:GetObject&quot;,
              &quot;s3:ListBucket&quot; 
            ],
            &quot;Resource&quot;: [
              &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point&quot;,
              &quot;arn:aws:s3:ap-south-1:9848032919:accesspoint/my-access-point/object/*&quot;
            ]
          }
        ]
      }

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When I try to access the bucket as Ali user…&lt;br&gt;
&lt;img src=&quot;https://github.com/user-attachments/assets/65d118f0-04f6-48e1-bc95-4586202d494c&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;I named Ali user as Goutham-Ali on my CLI . This works!!!&lt;/p&gt;
&lt;p&gt;OK If I try to access using Normal S3 URI&lt;br&gt;
&lt;img src=&quot;https://github.com/user-attachments/assets/991d8129-82f7-443b-95ec-0e3958749523&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;This will not work.. &lt;/p&gt;
&lt;p&gt;This brings us to end of this article.&lt;/p&gt;
&lt;p&gt;That’s it we have allowed access to the bucket/objects only using access point and also allowed cross account access using access point .&lt;/p&gt;
&lt;p&gt;Thanks for reading my blog, leave a like and comment if you read something useful, have a great day. &lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/712/aws-s3-access-points-in-2025-using-cli</guid>
<pubDate>Fri, 10 Jan 2025 15:44:49 +0000</pubDate>
</item>
</channel>
</rss>