DEV Community

How to Display Images from S3 in Next.js 16 (2026 Guide)

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 s3Client from the upload guide's lib/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/image is 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.

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/image doesn'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.

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, turning public/avatars/photo.jpg into 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: 300 means 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:

  1. Open your distribution โ†’ go to the Origins tab โ†’ select your origin โ†’ click Edit
  2. Under Origin access control settings, choose Origin access control settings (recommended)
  3. Click Create control setting, give it a name, and leave "Sign requests" as the default
  4. 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

Comments

No comments yet. Start the discussion.