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 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, .
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
SOCIAL SHARE CARD GENERATOR