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 <img> tag? Does next/image even work with S3? What if the file is private and shouldn't be reachable by just anyone with the link?
If you followed our guide on uploading files to S3 with Next.js 16, 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: how you display an image doesn't depend on how it was uploaded. It depends on one thing only — is the object public or private?
In this guide, you'll learn all three real-world patterns for displaying S3 images in Next.js 16:
- Public bucket URLs — the simplest option, for non-sensitive assets
- Presigned GET URLs — for private files that only the right user should see
- CloudFront CDN delivery — for performance and scale, with both public and signed variants
By the end, you'll know exactly which pattern fits your use case and have working code for each.
Why This Is Tricky
It's tempting to think "S3 has a URL, I'll just use that." Sometimes that's genuinely fine. But most real apps hit one of these problems fast:
- Not everything should be public. A user's private document or someone else's uploaded receipt shouldn't be reachable by anyone who guesses or copies the URL.
-
next/imagedoesn't know about S3 by default. Next.js's image optimizer blocks external domains until you explicitly allow them — skip this step and your images silently fail to load. - Raw S3 isn't a CDN. 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.
So the real question for every image you display is: "Who is allowed to see this, and does it need to be fast at scale?" That answer picks your pattern.
💡 Tip: 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 "public" file leaked something sensitive.
Prerequisites & Setup
This guide continues directly from our S3 upload guide and assumes:
- Next.js 16 with the App Router
- TypeScript in strict mode
- Tailwind CSS for the UI examples
- The same S3 bucket and
s3Clientfrom the upload guide'slib/s3-client.ts
If you haven't set up the S3 client yet, go set that up first — every pattern below reuses it.
Allow S3 (and CloudFront) in next.config
next/image 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 remotePatterns:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "devstacked-uploads-demo.s3.us-east-1.amazonaws.com",
},
{
protocol: "https",
hostname: "*.cloudfront.net", // for Pattern 3 later in this guide
},
],
},
};
export default nextConfig;
⚠️ Common Mistake: Forgetting this step and assuming
next/imageis broken. The error Next.js throws ("hostname is not configured") is exactly this — it's not a bug, it's the optimizer refusing to fetch from an unlisted domain.💡 Tip: If you're serving stable public URLs (Pattern 1 or the public side of Pattern 3), also tune
minimumCacheTTL:images: { minimumCacheTTL: 60 * 60 * 24 * 30, // cache optimized output for 30 days remotePatterns: [/* ... */], },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.
Pattern 1: Public Bucket URLs
How it works: the object is publicly readable, so its S3 URL works for anyone, forever — no signing, no expiry, no server involvement at all.
Browser → S3 object URL directly (no server request needed)
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.
Step 1: Allow Public Reads on Specific Objects
Rather than making the whole bucket public (don't do that), scope a bucket policy to just the folder holding public assets:
// s3-public-read-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadForPublicFolder",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::devstacked-uploads-demo/public/*"
}
]
}
What's happening here: this policy only opens read access to objects under the public/ prefix — everything else in the bucket (like uploads/ or large-uploads/ from the upload guide) stays private by default.
⚠️ Important: If your bucket still has Block all public access fully enabled (from the upload guide's setup), AWS will reject this policy outright with an "access denied" error, even though the policy itself is valid. Go to your bucket → Permissions → Block public access and uncheck the two bucket policy Block Public Access settings while leaving the ACL-related settings enabled. This allows a scoped public bucket policy without relying on public ACLs.
⚠️ Common Mistake: 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.
Step 2: Store the Public URL Format
// 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 "public/my photo.jpg" produces an invalid URL without it
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
return `https://${BUCKET_NAME}.s3.${REGION}.amazonaws.com/${encodedKey}`;
}
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.
⚠️ Common Mistake: Encoding the whole key with a single
encodeURIComponent(key)call. That also escapes the/characters in the path, turningpublic/avatars/photo.jpginto a single garbled segment instead of a valid nested path. Encode each path segment individually, as shown above.
Step 3: Render It with next/image
// components/public-avatar.tsx
import Image from "next/image";
import { getPublicUrl } from "@/lib/s3-urls";
interface Props {
objectKey: string; // e.g. "public/avatars/uuid-photo.jpg"
alt: string;
}
export function PublicAvatar({ objectKey, alt }: Props) {
return (
<Image
src={getPublicUrl(objectKey)}
alt={alt}
width={96}
height={96}
className="rounded-full object-cover"
/>
);
}
Because we added the S3 hostname to remotePatterns 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.
Pattern 2: Presigned GET URLs for Private Files
How it works: 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.
Browser → Next.js (ask "can I see this file?") → gets presigned GET URL
Browser → S3 directly, using that URL, before it expires
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.
Step 1: Generate the Presigned GET URL
// app/api/image-url/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3Client } from "@/lib/s3-client";
import { auth } from "@/lib/auth"; // your auth solution of choice
const URL_EXPIRY_SECONDS = 300; // 5 minutes
export async function GET(request: NextRequest) {
const key = request.nextUrl.searchParams.get("key");
const session = await auth();
if (!session?.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!key || !key.startsWith(`private/${session.userId}/`)) {
return NextResponse.json({ error: "Forbidden" }, { 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 });
}
What's happening here:
- We check the user is authenticated before doing anything else — a presigned URL is only as safe as the check that generates it
- The key check (
key.startsWith('private/${session.userId}/')) makes sure a user can only ever get a signed URL for their own files, not someone else's, even if they know another user's exact key -
expiresIn: 300means the link works for 5 minutes — long enough to load the image, short enough that a copied link goes stale quickly
⚠️ Common Mistake: Generating a presigned GET URL without checking who's asking. It's easy to think "it expires, so it's safe" — but a 5-minute window is still plenty of time for a leaked link to be misused if you skip the ownership check.
Step 2: Fetch and Display It Client-Side
// components/private-image.tsx
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
interface Props {
objectKey: string;
alt: string;
}
export function PrivateImage({ objectKey, alt }: Props) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/image-url?key=${encodeURIComponent(objectKey)}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUrl(data.url ?? null);
});
return () => {
cancelled = true;
};
}, [objectKey]);
if (!url) {
return <div className="h-24 w-24 rounded-lg bg-muted animate-pulse" />;
}
return (
<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="rounded-lg object-cover"
/>
);
}
Why unoptimized? 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.
This flag only bypasses Next.js's own optimization pipeline — it doesn't disable the browser's normal HTTP caching. The browser can still cache the actual image bytes according to whatever Cache-Control header S3 returns on the object itself. Many developers assume unoptimized means "never cached at all," 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.
💡 Tip: For a server component instead of a client component, you can generate the presigned URL directly during the render on the server — no
useEffect, 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.
Pattern 3: CloudFront CDN Delivery
How it works: instead of the browser talking to S3 directly, it talks to a CloudFront distribution — 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.
Browser → CloudFront (edge location near the user) → S3 (only on a cache miss)
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.
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.
Step 1: Create a CloudFront Distribution
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.
a) Create the distribution
In the AWS Console under CloudFront → Distributions → Create distribution:
- Under Origin domain, click into the field and select your S3 bucket from the dropdown (it appears automatically once you start typing the bucket name)
- Leave Origin path empty unless you want CloudFront to only serve a subfolder of your bucket
- Under Viewer protocol policy, choose Redirect HTTP to HTTPS
- Leave the rest of the defaults for now and click Create distribution
Creation takes a few minutes to fully deploy — CloudFront needs to propagate your distribution to edge locations worldwide before it's live everywhere.
b) Attach Origin Access Control (for private objects)
If your bucket (or the folder you're serving) is private, you need CloudFront to be the only thing allowed to read from it directly:
- Open your distribution → go to the Origins tab → select your origin → click Edit
- Under Origin access control settings, choose Origin access control settings (recommended)
- Click Create control setting, give it a name, and leave "Sign requests" as the default
- Save — CloudFront will show a banner warning that the bucket policy still needs updating. That's expected; that's the next step.
c) Update the bucket policy to allow CloudFront
AWS actually generates the exact policy JSON for you at the end of step (b) — look for a "Copy policy" button in that same banner. It looks like this:
// s3-cloudfront-oac-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::devstacked-uploads-demo/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXAMPLE_ID"
}
}
}
]
}
Paste this into your bucket → Permissions → Bucket policy. If you also have the public-read policy from Pattern 1, both statements can coexist in the same policy's Statement array — one scopes access to CloudFront's service principal, the other to Principal: "*" under the public/ prefix.
⚠️ Common Mistake: Copying an OAC policy example from an older tutorial that references the deprecated Origin Access Identity (OAI) instead of OAC. AWS now recommends OAC for all new distributions — OAI still works but lacks OAC's support for all S3 request types and encryption options.
d) Get your CloudFront domain
Once the distribution's status shows Enabled (back on the distributions list), copy the value under Domain name — it looks like d111111abcdef8.cloudfront.net. That's the CLOUDFRONT_DOMAIN value you'll use in the code below. If you own a custom domain, you can also attach it under Alternate domain names (CNAMEs) with a matching ACM certificate, but the default .cloudfront.net domain works fine to get started.
⚠️ Common Mistake: 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.
Step 2: Serve Public Assets Through CloudFront
For public assets, this is a one-line change from Pattern 1 — swap the S3 hostname for your CloudFront domain:
// lib/s3-urls.ts
const CLOUDFRONT_DOMAIN = process.env.CLOUDFRONT_DOMAIN!; // e.g. "d111111abcdef8.cloudfront.net"
export function getPublicUrl(key: string): string {
return `https://${CLOUDFRONT_DOMAIN}/${key}`;
}
Nothing else about Pattern 1's code changes — same next/image component, same remotePatterns entry (already added in setup), just a faster, cached path to the same file.
Step 3: Serve Private Assets with Signed CloudFront URLs
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:
// lib/cloudfront-signer.ts
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
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
});
}
Install the extra package this needs:
npm install @aws-sdk/cloudfront-signer
What's happening here: getSignedUrl 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.
Where to create that key pair:
- In the AWS Console, go to CloudFront → Key management → Public keys → Create public key
- This requires an RSA key pair. Generate one locally first:
openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
- Paste the contents of
public_key.peminto the Create public key form and save - Go to Key management → Key groups → Create key group, and add the public key you just created to it
- Back in your distribution's Behaviors tab, edit the relevant behavior and set Restrict viewer access to Yes, then select your key group under Trusted key groups
- Note the Key ID shown after creating the public key — that's your
CLOUDFRONT_KEY_PAIR_ID. The contents ofprivate_key.pem(never the public key) go intoCLOUDFRONT_PRIVATE_KEY
⚠️ Common Mistake: Adding the public key to CloudFront but forgetting step 5 — setting Restrict viewer access 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.
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.
💡 Tip: If most of your private files belong to logged-in users viewing many images per page (a dashboard, a gallery), look into signed cookies 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.
Which Pattern Should You Actually Use?
| Public Bucket URLs | Presigned GET URLs | CloudFront CDN | |
|---|---|---|---|
| Setup complexity | Low — one bucket policy | Medium — auth check + route handler | Higher — distribution, OAC, key pairs |
| Latency | Depends on bucket region | Depends on bucket region | Low — served from nearby edge locations |
| Caching | Browser cache only | Effectively none (URL changes each time) | Edge-cached, huge win at scale |
| Access control | None — anyone with the URL | Per-request, ownership-checked | Optional — public or signed |
| Best for | Non-sensitive, low-to-medium traffic assets | Private, user-specific files | Any file type at real scale |
| Situation | Pattern |
|---|---|
| Blog covers, public product photos, anything anyone can see | Public bucket URLs |
| Small app, low traffic, simplicity matters more than speed | Public bucket URLs |
| Private user documents, receipts, anything tied to one account | Presigned GET URLs |
| You need an ownership check before showing the file | Presigned GET URLs |
| Public images at real scale — you want edge caching and lower latency | CloudFront (public) |
| Private files at scale, or many private images per page | CloudFront (signed URLs or cookies) |
A common real-world setup uses all three at once: 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 upload guide.
Frequently Asked Questions
Can I just use a plain <img> tag instead of next/image?
Yes, especially for presigned URLs that change on every load, where next/image's optimization caching adds little value anyway. next/image is worth it for public, stable URLs where resizing and format conversion (WebP/AVIF) genuinely save bandwidth.
Do I need CloudFront if I'm only using presigned URLs?
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.
Why does my S3 image 403 even though I set a bucket policy?
Usually one of two things: the object key in your policy's Resource 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.
Should I store the S3 key or the full URL in my database?
Store the key (e.g. public/avatars/uuid.jpg), 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.
Can I resize images on the fly instead of storing multiple sizes?
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 next/image's built-in optimization isn't enough for your use case.
Why does my image download instead of render in the browser?
This happens when the object's Content-Disposition header is set to attachment instead of inline — 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:
new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
Body: buffer,
ContentType: file.type,
ContentDisposition: "inline", // ensures browsers render it instead of downloading it
})
If files are already uploaded with the wrong header, you can fix them without re-uploading by copying the object onto itself with CopyObjectCommand and the corrected ContentDisposition and MetadataDirective: "REPLACE".
Wrapping Up
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 upload guide, you've got the full loop covered: getting files into S3 the right way, and getting them back out safely and fast.
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.
📦 Source Code: View on GitHub
Top comments (0)