Build a Private Client Photo Gallery
Add a secure client photo gallery to your Next.js site using Cloudflare R2.
Introduction
30 Second Summary
When a photographer finishes a client session, sharing the photos usually means uploading to a third-party gallery service or sending a clunky download link. What if you could give each client their own private, password-protected gallery right on your website?
In this project, you will build a private client photo gallery into your existing Next.js photography site. Clients log in with credentials you set up, view their session photos in a responsive grid, and download individual shots or the entire set as a zip file.
What You'll Build
A credentials-based authentication system and protected gallery page that serves private photos from Cloudflare R2 via time-limited presigned URLs.
By the end of this project, you'll have:
- A client login system where each photography client authenticates with their own credentials.
- A protected photo gallery that displays only the logged-in client's session photos in a responsive grid with a full-screen lightbox.
- Individual and bulk download functionality so clients can save single photos or all photos as a zip.
- Secret Mission: Add photo favoriting so clients can mark their favorite shots from the session.
Are there any prerequisites?
This project builds on an existing Next.js 15 site with a Cloudflare R2 bucket already set up and photos uploaded. You also need Node.js installed on your machine.
Install Packages and Configure R2
Your Next.js site is live, but right now anyone could view it. To turn it into a private client gallery, you need three things: an authentication library, a way to talk to your R2 bucket from server-side code, and a secure place to store your credentials.
In this step, you will install the necessary packages, generate an authentication secret, and create a utility file that connects your app to Cloudflare R2.
In this step, get ready to:
- Install authentication and cloud storage packages.
- Set up environment variables for your R2 credentials and auth secret.
- Create an R2 client utility file that connects to your bucket.
Install the required packages
Your gallery needs several packages working together. NextAuth handles login sessions and route protection. The AWS SDK talks to your R2 bucket using the S3-compatible API. And bcryptjs securely hashes client passwords.
- Install the main dependencies by running this command in your project terminal:
npm install next-auth@beta @aws-sdk/client-s3 @aws-sdk/s3-request-presigner bcryptjs
- Install the TypeScript type definitions for bcryptjs by running:
npm install -D @types/bcryptjs
What do these packages do?
- next-auth@beta is Auth.js v5, built specifically for the Next.js App Router. It manages sessions, route protection, and login flows.
- @aws-sdk/client-s3 lets you list and fetch objects from any S3-compatible storage (including Cloudflare R2).
- @aws-sdk/s3-request-presigner generates time-limited URLs so the browser can access private bucket objects without exposing your credentials.
- bcryptjs is a pure JavaScript password hashing library. It avoids native compilation issues on Windows.
- Confirm the install succeeded by checking that no errors appeared in your terminal output.
Seeing permission errors?
On macOS, if you see an EACCES error, make sure you are running the terminal inside your project folder (not the system root). On Windows, try running your terminal as Administrator.
help me fix npm install permission errors in my Next.js project
Generate an auth secret and configure environment variables
NextAuth needs a secret key to encrypt session tokens. You also need your Cloudflare R2 credentials so the app can access your bucket at runtime.
- Generate the AUTH_SECRET by running this command:
npx auth secret
What does this command do?
This generates a random string, creates a .env.local file in your project root (if one doesn't exist), and adds an AUTH_SECRET variable to it. NextAuth uses this secret to sign and verify session cookies.
Next, you need your R2 credentials from the Cloudflare dashboard. You will collect your Account ID, an Access Key ID, a Secret Access Key, and your bucket name.
- Open your browser and sign in to the Cloudflare dashboard at dash.cloudflare.com.
- Select Storage & databases in the left sidebar, then R2, then Overview.
- Your Account ID is displayed as part of the S3 API endpoint URL on this page. It looks like a long hex string. Copy it and keep it handy.
- Select Manage next to API Tokens on the R2 Overview page.
- Select Create API token.
- Choose Object Read & Write permission and scope it to your photos bucket.
- Select Create API Token.
- Copy the Access Key ID and Secret Access Key values. Store them securely. You will not be able to view the secret again.
Save your credentials now
The Secret Access Key is shown only once. If you lose it, you will need to create a new token.
- Open .env.local in Cursor (it should already contain the AUTH_SECRET line from the previous command).
- Add your R2 credentials below the AUTH_SECRET line so the file looks like this:
AUTH_SECRET=<generated-secret>
R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
R2_BUCKET_NAME=your-bucket-name
What are these variables?
- R2_ACCOUNT_ID is the hex string from your R2 endpoint URL.
- R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are the credentials from the API token you just created.
- R2_BUCKET_NAME is the name of the R2 bucket where your client photos are stored.
- Replace each placeholder value with your actual credentials.
- Save .env.local.
Not sure where to find your bucket name?
Go back to the R2 Overview page in your Cloudflare dashboard. Your bucket name is listed under the Buckets section. It is the name you chose when you first created the bucket.
help me find my Cloudflare R2 bucket name and account ID
Create the R2 client utility
Every time your app needs to talk to R2 (listing photos, generating download links), it uses an S3Client. Instead of creating a new client in every file, you will define it once in a shared utility.
- Create a lib folder in your project root (if it does not already exist).
- Create a new file called r2.ts inside the lib folder and paste the following code:
import { S3Client } from "@aws-sdk/client-s3";
// Configure the S3-compatible client for Cloudflare R2
export const r2Client = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
// Export bucket name for use in API routes
export const R2_BUCKET = process.env.R2_BUCKET_NAME!;
What does this code do?
- region: "auto" is required by the SDK but not actually used by R2. Cloudflare handles region routing automatically.
- The endpoint points the SDK at your R2 bucket instead of AWS S3. The format is https://<ACCOUNT_ID>.r2.cloudflarestorage.com.
- The ! after each process.env value tells TypeScript "trust me, this variable exists at runtime."
- R2_BUCKET is exported so every API route can reference the bucket name without importing process.env directly.
- Save lib/r2.ts.
- Verify the app still compiles by running:
npm run dev
- Confirm the dev server starts without errors in the terminal output.
Seeing a module not found error?
If the terminal shows an error about @aws-sdk/client-s3 not being found, make sure the npm install command from earlier completed successfully. Try running it again.
help me debug a module not found error for @aws-sdk/client-s3 in my Next.js project
✔️ Awesome, I've got everything!
Great. Double check you have saved both .env.local and lib/r2.ts.
ⓧ I'd like to double check the full code
Here is the complete lib/r2.ts file:
import { S3Client } from "@aws-sdk/client-s3";
export const r2Client = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export const R2_BUCKET = process.env.R2_BUCKET_NAME!;
And here is what your .env.local should look like (with your actual values substituted):
AUTH_SECRET=<generated-secret>
R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
R2_BUCKET_NAME=your-bucket-name
Your project is now connected to Cloudflare R2 and ready for authentication. Next up, you will build the client login system so each photographer's client can sign in securely.
Add Client Authentication
Your environment is ready and the R2 connection is configured. Now you need a way to control who can access the photo gallery.
In this step, you will build a full authentication system using NextAuth (Auth.js v5). Each photography client gets their own email and password, and the system maps them to a specific folder in your R2 bucket where their session photos live.
In this step, get ready to:
- Create a client database with hashed passwords and R2 folder mappings.
- Configure NextAuth with the Credentials provider for email/password login.
- Build a login page and protect gallery routes with middleware.
Create the client data file
Before configuring authentication, you need to define who your clients are. Each client entry holds an email, a hashed password, and an R2 folder path pointing to their photos.
- Create a new file at lib/clients.ts and add the following code:
import bcrypt from "bcryptjs";
// Define the shape of a client record
export interface Client {
id: string;
name: string;
email: string;
passwordHash: string;
r2Folder: string;
}
// Client list with hashed passwords and R2 folder mappings
export const clients: Client[] = [
{
id: "1",
name: "Sarah Johnson",
email: "sarah@example.com",
passwordHash: bcrypt.hashSync("sarah-session-2024", 10),
r2Folder: "clients/sarah-johnson-wedding/",
},
{
id: "2",
name: "Mike Chen",
email: "mike@example.com",
passwordHash: bcrypt.hashSync("mike-portrait-2024", 10),
r2Folder: "clients/mike-chen-portrait/",
},
];
// Look up a client by their email address
export function getClientByEmail(email: string): Client | undefined {
return clients.find((c) => c.email === email);
}
// Look up a client by their unique ID
export function getClientById(id: string): Client | undefined {
return clients.find((c) => c.id === id);
}
What does this code do?
The Client interface defines the shape of each client record. Every client has a unique ID, display name, email, password hash, and a folder path in R2.
The bcrypt.hashSync call hashes each password so you never store plain text credentials in your code. The r2Folder value tells the gallery API exactly which R2 prefix to list photos from when this client logs in.
The two helper functions (getClientByEmail and getClientById) provide quick lookups used later by the auth and gallery code.
Why a config file instead of a database?
As a photographer with a manageable number of clients, a config file keeps this project focused on the gallery feature without introducing database setup overhead. You can always migrate to a database later as your client list grows.
- Save lib/clients.ts.
Seeing a TypeScript error on bcrypt?
Make sure you installed @types/bcryptjs as a dev dependency in Step 1. Without it, TypeScript cannot resolve the bcryptjs import.
help me fix a TypeScript import error for bcryptjs
Configure NextAuth with the Credentials provider
Now you need to tell NextAuth how to verify client logins. The Credentials provider lets you define a custom authorize function that checks email and password against your client list.
- Create a new file called auth.ts at the root of your project (the same level as package.json) and add this code:
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { getClientByEmail } from "@/lib/clients";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
const email = credentials.email as string;
const password = credentials.password as string;
// Look up the client by email
const client = getClientByEmail(email);
if (!client) return null;
// Verify the password against the stored hash
const isValid = await bcrypt.compare(password, client.passwordHash);
if (!isValid) return null;
// Return the user object for the session
return {
id: client.id,
name: client.name,
email: client.email,
};
},
}),
],
pages: {
signIn: "/login",
},
callbacks: {
authorized: async ({ auth }) => {
return !!auth;
},
},
});
What does this code do?
The authorize function runs every time someone submits the login form. It looks up the email in your client list, then uses bcrypt.compare to check the submitted password against the stored hash.
If verification passes, it returns a user object that NextAuth stores in the session. If it fails, it returns null and the login is rejected.
The pages.signIn setting tells NextAuth to redirect unauthenticated users to /login instead of its default sign-in page. The authorized callback returns true only when a session exists, which the middleware uses to decide whether to block a request.
- Save auth.ts.
Module not found error for @/lib/clients?
The @/ path alias is configured in your tsconfig.json by default in Next.js projects. Make sure lib/clients.ts is saved in the lib folder at the root of your project.
help me fix a module not found error for @/lib/clients in my auth.ts file
✔️ Awesome, I've got everything!
Great. Make sure both lib/clients.ts and auth.ts are saved.
ⓧ I'd like to double check the full code
Here is lib/clients.ts:
import bcrypt from "bcryptjs";
export interface Client {
id: string;
name: string;
email: string;
passwordHash: string;
r2Folder: string;
}
export const clients: Client[] = [
{
id: "1",
name: "Sarah Johnson",
email: "sarah@example.com",
passwordHash: bcrypt.hashSync("sarah-session-2024", 10),
r2Folder: "clients/sarah-johnson-wedding/",
},
{
id: "2",
name: "Mike Chen",
email: "mike@example.com",
passwordHash: bcrypt.hashSync("mike-portrait-2024", 10),
r2Folder: "clients/mike-chen-portrait/",
},
];
export function getClientByEmail(email: string): Client | undefined {
return clients.find((c) => c.email === email);
}
export function getClientById(id: string): Client | undefined {
return clients.find((c) => c.id === id);
}
Here is auth.ts:
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { getClientByEmail } from "@/lib/clients";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
const email = credentials.email as string;
const password = credentials.password as string;
const client = getClientByEmail(email);
if (!client) return null;
const isValid = await bcrypt.compare(password, client.passwordHash);
if (!isValid) return null;
return {
id: client.id,
name: client.name,
email: client.email,
};
},
}),
],
pages: {
signIn: "/login",
},
callbacks: {
authorized: async ({ auth }) => {
return !!auth;
},
},
});
Set up the route handler and middleware
NextAuth needs a route handler to process login and logout requests. You also need middleware to intercept requests to protected routes and redirect unauthenticated visitors to the login page.
- Create the folder path app/api/auth/[...nextauth]/ and add a file called route.ts inside it with this code:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
- Create a file called middleware.ts at the root of your project (same level as auth.ts) and add this code:
// Re-export auth as middleware to protect matched routes
export { auth as middleware } from "@/auth";
// Define which routes require authentication
export const config = {
matcher: ["/gallery/:path*", "/api/gallery/:path*", "/api/download/:path*"],
};
What do these files do?
The route handler at app/api/auth/[...nextauth]/route.ts is a catch-all API route. It handles all auth-related HTTP requests (sign in, sign out, session checks) by delegating to the NextAuth handlers you exported from auth.ts.
The middleware runs before every request that matches the matcher patterns. If the user has no valid session, they get redirected to /login. This protects the gallery page and all gallery-related API routes.
- Save both app/api/auth/[...nextauth]/route.ts and middleware.ts.
Middleware not redirecting?
Make sure middleware.ts is at the project root (next to package.json), not inside the app folder. Next.js only picks up middleware from the project root.
help me debug why my Next.js middleware is not intercepting requests
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Here is middleware.ts:
export { auth as middleware } from "@/auth";
export const config = {
matcher: ["/gallery/:path*", "/api/gallery/:path*", "/api/download/:path*"],
};
Build the login page
The last piece of the auth system is a login form. This page collects the client's email and password, calls the NextAuth signIn function, and redirects to the gallery on success.
- Create the folder app/login/ and add a file called page.tsx inside it with this code:
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
// Call NextAuth signIn with credentials
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Invalid email or password");
setLoading(false);
} else {
// Redirect to gallery on success
router.push("/gallery");
}
};
return (
<div className="login-container">
<h1>Client Gallery Login</h1>
<p>Enter your credentials to view your photos</p>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
</div>
);
}
What does this code do?
The "use client" directive marks this as a Client Component because it uses React state and event handlers.
When the form is submitted, signIn("credentials", { redirect: false }) sends the email and password to the NextAuth route handler. Setting redirect: false lets you handle errors in the UI rather than following a server redirect.
On success, router.push("/gallery") navigates the client to their photo gallery. On failure, the error message is displayed below the form.
- Save app/login/page.tsx.
- Return to your terminal and start the dev server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000/gallery.
- You should be redirected to /login automatically.
- Enter sarah@example.com for the email and sarah-session-2024 for the password.
- Click Sign In.
- Your browser URL should change to /gallery. You will see a 404 page because the gallery page does not exist yet. The important thing is that the redirect and login flow works.
Still on the login page after clicking Sign In?
If you see "Invalid email or password", double-check that the email and password match exactly what is in lib/clients.ts (sarah@example.com and sarah-session-2024).
If nothing happens at all, check your terminal for errors. A common cause is a missing AUTH_SECRET in your .env.local file.
help me debug why my NextAuth credentials login is not working
✔️ Awesome, I've got everything!
Great. Make sure app/login/page.tsx is saved.
ⓧ I'd like to double check the full code
Here is app/login/page.tsx:
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Invalid email or password");
setLoading(false);
} else {
router.push("/gallery");
}
};
return (
<div className="login-container">
<h1>Client Gallery Login</h1>
<p>Enter your credentials to view your photos</p>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
</div>
);
}
Your authentication system is fully wired up. Clients can log in, and unauthorized visitors get bounced to the login page. Next up, you will build the photo gallery that authenticated clients see after logging in.
Build the Photo Gallery
Your authentication system is working. Clients can log in and reach the gallery page. Now it's time to fill that page with their actual photos.
In this step, you'll build an API route that securely pulls each client's photos from Cloudflare R2 using presigned URLs. Then you'll create a responsive gallery page that displays those photos in a grid with a full-screen lightbox viewer.
In this step, get ready to:
- Create an API route that lists photos from your client's R2 folder.
- Build a responsive photo gallery page with a thumbnail grid.
- Add a full-screen lightbox viewer with previous/next navigation.
Create the gallery API route
The gallery page needs a backend endpoint that knows which photos belong to the logged-in client. This API route reads the user's session, looks up their R2 folder path, lists the objects in that folder, and generates time-limited presigned URLs so the browser can load images directly from R2 without the bucket being public.
- First, update your root layout to wrap the app with SessionProvider. This enables client-side session features like signOut in your gallery page.
- Open app/layout.tsx and replace its contents with the following code:
import { SessionProvider } from "next-auth/react";
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}
What does this code do?
SessionProvider is a React context provider from next-auth. It wraps your entire app so that client components can access session data and use functions like signOut.
Without it, calling signOut() from a client component would throw an error because no session context would be available.
- Save app/layout.tsx.
- Now create the gallery API route. Create a new file at app/api/gallery/route.ts and paste the following code:
import { auth } from "@/auth";
import { r2Client, R2_BUCKET } from "@/lib/r2";
import { ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getClientById } from "@/lib/clients";
import { NextResponse } from "next/server";
export const GET = auth(async function GET(req) {
// Return 401 if user is not authenticated
if (!req.auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Look up the client's R2 folder path
const client = getClientById(req.auth.user?.id as string);
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
// List all objects in the client's folder
const listCommand = new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: client.r2Folder,
});
const response = await r2Client.send(listCommand);
const objects = response.Contents || [];
// Filter to only image files
const imageExtensions = [".jpg", ".jpeg", ".png", ".webp"];
const images = objects.filter((obj) =>
imageExtensions.some((ext) => obj.Key?.toLowerCase().endsWith(ext))
);
// Generate a presigned URL for each image (valid for 1 hour)
const photos = await Promise.all(
images.map(async (obj) => {
const url = await getSignedUrl(
r2Client,
new GetObjectCommand({ Bucket: R2_BUCKET, Key: obj.Key }),
{ expiresIn: 3600 }
);
return {
key: obj.Key!,
name: obj.Key!.split("/").pop()!,
url,
size: obj.Size,
lastModified: obj.LastModified,
};
})
);
return NextResponse.json({ photos });
});
What does this code do?
- auth() wraps the route handler so that req.auth contains the logged-in user's session. If the user is not authenticated, the middleware already blocks access, but this adds a safety check.
- ListObjectsV2Command lists all objects in the client's R2 folder (like browsing a specific directory).
- getSignedUrl generates a temporary URL for each image that expires after 3600 seconds (1 hour). The browser can load images directly from R2 using these URLs without the bucket being publicly accessible.
- The response returns a JSON array of photo objects with the key, filename, presigned URL, size, and last modified date.
- Save app/api/gallery/route.ts.
Why presigned URLs instead of proxying through the server?
Presigned URLs let the client's browser download images directly from R2. This offloads bandwidth from your Next.js server and is faster for the end user. The URL is temporary, so even if someone shares it, access expires after one hour.
Seeing a module not found error?
Make sure you have both @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner installed from Step 1. Check your package.json to confirm they are listed under dependencies.
help me debug a module not found error in my gallery API route
✔️ Awesome, I've got everything!
Great. Make sure you have saved both app/layout.tsx and app/api/gallery/route.ts.
ⓧ I'd like to double check the full code
Here is the full app/api/gallery/route.ts:
import { auth } from "@/auth";
import { r2Client, R2_BUCKET } from "@/lib/r2";
import { ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getClientById } from "@/lib/clients";
import { NextResponse } from "next/server";
export const GET = auth(async function GET(req) {
if (!req.auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const client = getClientById(req.auth.user?.id as string);
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
const listCommand = new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: client.r2Folder,
});
const response = await r2Client.send(listCommand);
const objects = response.Contents || [];
const imageExtensions = [".jpg", ".jpeg", ".png", ".webp"];
const images = objects.filter((obj) =>
imageExtensions.some((ext) => obj.Key?.toLowerCase().endsWith(ext))
);
const photos = await Promise.all(
images.map(async (obj) => {
const url = await getSignedUrl(
r2Client,
new GetObjectCommand({ Bucket: R2_BUCKET, Key: obj.Key }),
{ expiresIn: 3600 }
);
return {
key: obj.Key!,
name: obj.Key!.split("/").pop()!,
url,
size: obj.Size,
lastModified: obj.LastModified,
};
})
);
return NextResponse.json({ photos });
});
And here is the full app/layout.tsx:
import { SessionProvider } from "next-auth/react";
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}
Build the gallery page
With the API route ready, you need a frontend page to call it and display the photos. The gallery page fetches the list of presigned URLs on load and renders each photo in a responsive CSS grid.
- Create a new file at app/gallery/page.tsx and paste the following code:
"use client";
import { useEffect, useState } from "react";
import { signOut } from "next-auth/react";
interface Photo {
key: string;
name: string;
url: string;
size: number;
lastModified: string;
}
export default function GalleryPage() {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Fetch the client's photos from the API on page load
useEffect(() => {
fetch("/api/gallery")
.then((res) => res.json())
.then((data) => {
setPhotos(data.photos || []);
setLoading(false);
});
}, []);
// Lightbox navigation functions
const openLightbox = (index: number) => setLightboxIndex(index);
const closeLightbox = () => setLightboxIndex(null);
const goNext = () => {
if (lightboxIndex !== null) {
setLightboxIndex((lightboxIndex + 1) % photos.length);
}
};
const goPrev = () => {
if (lightboxIndex !== null) {
setLightboxIndex((lightboxIndex - 1 + photos.length) % photos.length);
}
};
// Download helpers (these call API routes we'll build in the next step)
const downloadPhoto = (key: string) => {
window.open(`/api/download?key=${encodeURIComponent(key)}`, "_blank");
};
const downloadAll = () => {
window.open("/api/download/all", "_blank");
};
if (loading) return <div className="gallery-loading">Loading your photos...</div>;
return (
<div className="gallery-container">
<header className="gallery-header">
<h1>Your Photo Gallery</h1>
<div className="gallery-actions">
<button onClick={downloadAll} className="download-all-btn">
Download All ({photos.length} photos)
</button>
<button onClick={() => signOut({ callbackUrl: "/login" })} className="signout-btn">
Sign Out
</button>
</div>
</header>
<div className="photo-grid">
{photos.map((photo, index) => (
<div key={photo.key} className="photo-card">
<img
src={photo.url}
alt={photo.name}
onClick={() => openLightbox(index)}
loading="lazy"
/>
<div className="photo-overlay">
<span className="photo-name">{photo.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
downloadPhoto(photo.key);
}}
className="download-btn"
>
Download
</button>
</div>
</div>
))}
</div>
</div>
);
}
What does this code do?
- The useEffect hook calls /api/gallery when the page loads and stores the photo data in state.
- The photo-grid div uses CSS Grid to lay out thumbnails responsively. Each image uses loading="lazy" so the browser only fetches images as they scroll into view.
- Each photo card has an overlay with the filename and a download button.
- The signOut button in the header signs the client out and redirects them back to the login page.
- Save app/gallery/page.tsx.
- Return to your terminal and confirm the dev server is running with npm run dev.
- Open http://localhost:3000/gallery in your browser. You should be redirected to /login.
- Sign in with sarah@example.com and password sarah-session-2024.
- You should see a photo grid displaying images from the clients/sarah-johnson-wedding/ folder in your R2 bucket.
Seeing an empty grid or no photos?
Confirm your R2 bucket has images uploaded in a folder matching the path in lib/clients.ts (e.g., clients/sarah-johnson-wedding/). The folder path must match exactly, including the trailing slash.
Also check that your .env.local credentials are correct for your R2 bucket. If you see a network error in the browser console, the API route may be failing to connect to R2.
help me debug why my gallery is showing no photos from R2
Add the lightbox overlay
Clicking a thumbnail should open a full-screen view of the photo. The lightbox lets clients browse through their photos one at a time with previous/next buttons.
- In app/gallery/page.tsx, find the closing </div> tag for the gallery-container at the bottom of your return statement. It looks like this:
</div>
</div>
);
}
- Add the following lightbox code between the photo-grid closing tag and the gallery-container closing tag, so the lightbox sits inside the container:
{lightboxIndex !== null && (
<div className="lightbox" onClick={closeLightbox}>
<button className="lightbox-close" onClick={closeLightbox}>
X
</button>
<button
className="lightbox-prev"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
>
Previous
</button>
<img
src={photos[lightboxIndex].url}
alt={photos[lightboxIndex].name}
onClick={(e) => e.stopPropagation()}
/>
<button
className="lightbox-next"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
>
Next
</button>
<div className="lightbox-info">
<span>{photos[lightboxIndex].name}</span>
<button onClick={() => downloadPhoto(photos[lightboxIndex].key)}>
Download
</button>
</div>
</div>
)}
What does this code do?
- The lightbox only renders when lightboxIndex is not null. Clicking a thumbnail calls openLightbox(index) which sets the index and triggers the overlay to appear.
- Clicking the dark background (the outer div) calls closeLightbox() to dismiss it. The e.stopPropagation() calls on the buttons and image prevent those clicks from also closing the lightbox.
- goNext() and goPrev() cycle through photos using modulo arithmetic, so the navigation wraps around from last to first and vice versa.
- Save app/gallery/page.tsx.
- Refresh your browser on the gallery page.
- Click any photo thumbnail. You should see the image appear full-screen with Previous, Next, and X (close) buttons.
- Click Previous or Next to navigate between photos, then click X or the dark background to close the lightbox.
Lightbox not opening when you click a photo?
Check that the onClick={() => openLightbox(index)} attribute is on the <img> tag inside photo-grid. If you accidentally placed the lightbox JSX outside the gallery-container div, it will not render correctly.
help me fix my lightbox not opening in the gallery page
✔️ Awesome, I've got everything!
Great. Double check you have saved app/gallery/page.tsx with both the grid and the lightbox code.
ⓧ I'd like to double check the full code
Here is the complete app/gallery/page.tsx:
"use client";
import { useEffect, useState } from "react";
import { signOut } from "next-auth/react";
interface Photo {
key: string;
name: string;
url: string;
size: number;
lastModified: string;
}
export default function GalleryPage() {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
useEffect(() => {
fetch("/api/gallery")
.then((res) => res.json())
.then((data) => {
setPhotos(data.photos || []);
setLoading(false);
});
}, []);
const openLightbox = (index: number) => setLightboxIndex(index);
const closeLightbox = () => setLightboxIndex(null);
const goNext = () => {
if (lightboxIndex !== null) {
setLightboxIndex((lightboxIndex + 1) % photos.length);
}
};
const goPrev = () => {
if (lightboxIndex !== null) {
setLightboxIndex((lightboxIndex - 1 + photos.length) % photos.length);
}
};
const downloadPhoto = (key: string) => {
window.open(`/api/download?key=${encodeURIComponent(key)}`, "_blank");
};
const downloadAll = () => {
window.open("/api/download/all", "_blank");
};
if (loading) return <div className="gallery-loading">Loading your photos...</div>;
return (
<div className="gallery-container">
<header className="gallery-header">
<h1>Your Photo Gallery</h1>
<div className="gallery-actions">
<button onClick={downloadAll} className="download-all-btn">
Download All ({photos.length} photos)
</button>
<button onClick={() => signOut({ callbackUrl: "/login" })} className="signout-btn">
Sign Out
</button>
</div>
</header>
<div className="photo-grid">
{photos.map((photo, index) => (
<div key={photo.key} className="photo-card">
<img
src={photo.url}
alt={photo.name}
onClick={() => openLightbox(index)}
loading="lazy"
/>
<div className="photo-overlay">
<span className="photo-name">{photo.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
downloadPhoto(photo.key);
}}
className="download-btn"
>
Download
</button>
</div>
</div>
))}
</div>
{lightboxIndex !== null && (
<div className="lightbox" onClick={closeLightbox}>
<button className="lightbox-close" onClick={closeLightbox}>
X
</button>
<button
className="lightbox-prev"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
>
Previous
</button>
<img
src={photos[lightboxIndex].url}
alt={photos[lightboxIndex].name}
onClick={(e) => e.stopPropagation()}
/>
<button
className="lightbox-next"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
>
Next
</button>
<div className="lightbox-info">
<span>{photos[lightboxIndex].name}</span>
<button onClick={() => downloadPhoto(photos[lightboxIndex].key)}>
Download
</button>
</div>
</div>
)}
</div>
);
}
Your gallery is live. Clients can log in and browse their session photos in a beautiful grid with a full-screen viewer. Next up, you'll add download functionality so clients can save individual photos or grab all of them at once as a zip file.
Add Photo Downloads
Your clients can browse their photos in a beautiful grid and view them in a lightbox. But what good is a gallery if they can't save the images?
In this step, you'll create two API routes that handle downloads. One generates a secure download link for a single photo. The other bundles every photo into a zip file so clients can grab everything at once.
In this step, get ready to:
- Create a single-photo download API route with presigned URLs.
- Create a bulk download API route that zips all client photos.
- Verify that individual and bulk downloads work from the gallery.
Create the single-photo download route
When a client clicks a download button on a thumbnail, the browser needs somewhere to go. Your gallery page already calls /api/download?key=... but the route doesn't exist yet.
Why presigned URLs for downloads?
Your R2 bucket is private. Instead of proxying the entire file through your server, you generate a short-lived URL that grants temporary read access directly from R2.
For downloads specifically, you also set a ResponseContentDisposition header override. This tells the browser to save the file rather than display it inline.
- Create the folder path app/api/download/ inside your project.
- Create a new file called route.ts inside app/api/download/ and paste in the following code:
import { auth } from "@/auth";
import { r2Client, R2_BUCKET } from "@/lib/r2";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getClientById } from "@/lib/clients";
import { NextResponse } from "next/server";
export const GET = auth(async function GET(req) {
if (!req.auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Read the photo key from the query string
const { searchParams } = new URL(req.url);
const key = searchParams.get("key");
if (!key) {
return NextResponse.json({ error: "Missing key parameter" }, { status: 400 });
}
// Verify the requested key belongs to this client's folder
const client = getClientById(req.auth.user?.id as string);
if (!client || !key.startsWith(client.r2Folder)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// Generate a presigned URL that forces a file download
const filename = key.split("/").pop()!;
const url = await getSignedUrl(
r2Client,
new GetObjectCommand({
Bucket: R2_BUCKET,
Key: key,
ResponseContentDisposition: `attachment; filename="${filename}"`,
}),
{ expiresIn: 300 }
);
// Redirect the browser to the presigned download URL
return NextResponse.redirect(url);
});
What does this code do?
- It wraps the handler with auth() so only logged-in users can reach it.
- It reads the key query parameter and checks it starts with the client's r2Folder path. This prevents one client from downloading another client's photos.
- It generates a presigned URL with a 5-minute expiry and a Content-Disposition: attachment header so the browser downloads instead of displaying inline.
- It redirects the browser to that URL so the download starts immediately.
- Save app/api/download/route.ts.
You won't see a change yet because the gallery buttons already call this route. The next chunk adds the bulk download route, then you'll test both together.
Create the bulk download route
The "Download All" button calls /api/download/all and expects a zip file back. To create zip archives in Node.js, you'll use the archiver package.
- Install archiver and its TypeScript definitions by running:
npm install archiver
npm install -D @types/archiver
What is archiver?
Archiver is a Node.js library for creating zip and tar archives using streams. It handles compression, file naming, and buffering so you don't have to implement the zip format yourself.
- Confirm the install succeeded by checking that no errors appeared in the terminal output.
- Create a new folder app/api/download/all/ inside your project.
- Create a file called route.ts inside app/api/download/all/ and paste in the following code:
import { auth } from "@/auth";
import { r2Client, R2_BUCKET } from "@/lib/r2";
import { ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { getClientById } from "@/lib/clients";
import { NextResponse } from "next/server";
export const GET = auth(async function GET(req) {
if (!req.auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const client = getClientById(req.auth.user?.id as string);
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
// List all objects in the client's R2 folder
const listCommand = new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: client.r2Folder,
});
const response = await r2Client.send(listCommand);
const objects = response.Contents || [];
// Filter to image files only
const imageExtensions = [".jpg", ".jpeg", ".png", ".webp"];
const images = objects.filter((obj) =>
imageExtensions.some((ext) => obj.Key?.toLowerCase().endsWith(ext))
);
// Dynamically import archiver and stream utilities
const { Readable } = await import("stream");
const archiver = (await import("archiver")).default;
// Create a zip archive with moderate compression
const archive = archiver("zip", { zlib: { level: 5 } });
const chunks: Buffer[] = [];
archive.on("data", (chunk: Buffer) => chunks.push(chunk));
// Fetch each image from R2 and append it to the zip
for (const obj of images) {
const getCommand = new GetObjectCommand({
Bucket: R2_BUCKET,
Key: obj.Key,
});
const file = await r2Client.send(getCommand);
const stream = file.Body as NodeJS.ReadableStream;
const filename = obj.Key!.split("/").pop()!;
archive.append(Readable.from(stream as any), { name: filename });
}
await archive.finalize();
// Combine all chunks into a single buffer and return as a zip
const buffer = Buffer.concat(chunks);
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${client.name.replace(/\s+/g, "-")}-photos.zip"`,
},
});
});
What does this code do?
- It lists all objects in the client's R2 folder and filters for image file extensions.
- It dynamically imports archiver and Node's Readable stream utility. Dynamic imports keep the server bundle smaller for routes that don't use archiver.
- For each image, it fetches the file body from R2 and appends it to the zip archive.
- After archive.finalize() completes, it concatenates all buffered chunks and returns the zip with appropriate headers.
- The filename is built from the client's name (e.g. Sarah-Johnson-photos.zip).
- Save app/api/download/all/route.ts.
Seeing a module not found error for archiver?
Make sure you ran npm install archiver in the same terminal where your project lives. Restart the dev server after installing new packages.
Help me debug this archiver module error.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for both new files.
import { auth } from "@/auth";
import { r2Client, R2_BUCKET } from "@/lib/r2";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getClientById } from "@/lib/clients";
import { NextResponse } from "next/server";
export const GET = auth(async function GET(req) {
if (!req.auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const key = searchParams.get("key");
if (!key) {
return NextResponse.json({ error: "Missing key parameter" }, { status: 400 });
}
const client = getClientById(req.auth.user?.id as string);
if (!client || !key.startsWith(client.r2Folder)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const filename = key.split("/").pop()!;
const url = await getSignedUrl(
r2Client,
new GetObjectCommand({
Bucket: R2_BUCKET,
Key: key,
ResponseContentDisposition: `attachment; filename="${filename}"`,
}),
{ expiresIn: 300 }
);
return NextResponse.redirect(url);
});
import { auth } from "@/auth";
import { r2Client, R2_BUCKET } from "@/lib/r2";
import { ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { getClientById } from "@/lib/clients";
import { NextResponse } from "next/server";
export const GET = auth(async function GET(req) {
if (!req.auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const client = getClientById(req.auth.user?.id as string);
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
const listCommand = new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: client.r2Folder,
});
const response = await r2Client.send(listCommand);
const objects = response.Contents || [];
const imageExtensions = [".jpg", ".jpeg", ".png", ".webp"];
const images = objects.filter((obj) =>
imageExtensions.some((ext) => obj.Key?.toLowerCase().endsWith(ext))
);
const { Readable } = await import("stream");
const archiver = (await import("archiver")).default;
const archive = archiver("zip", { zlib: { level: 5 } });
const chunks: Buffer[] = [];
archive.on("data", (chunk: Buffer) => chunks.push(chunk));
for (const obj of images) {
const getCommand = new GetObjectCommand({
Bucket: R2_BUCKET,
Key: obj.Key,
});
const file = await r2Client.send(getCommand);
const stream = file.Body as NodeJS.ReadableStream;
const filename = obj.Key!.split("/").pop()!;
archive.append(Readable.from(stream as any), { name: filename });
}
await archive.finalize();
const buffer = Buffer.concat(chunks);
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${client.name.replace(/\s+/g, "-")}-photos.zip"`,
},
});
});
Verify the download buttons work
Your gallery page already has download buttons wired up from the previous step. Now that the API routes exist, everything should work end-to-end.
- Make sure your dev server is running. If it isn't, start it by running:
npm run dev
- Open your browser and navigate to http://localhost:3000/gallery.
- Sign in with your test credentials (e.g. sarah@example.com / sarah-session-2024).
- Hover over a photo thumbnail and click the **Download** button that appears in the overlay.
- Confirm the photo downloads to your computer as a single image file.
- Click the **Download All** button at the top of the gallery.
- Confirm a zip file downloads containing all the client's photos.
What should I see?
The single-photo download opens a new tab briefly and then saves the image file. The "Download All" button may take a few seconds depending on how many photos are in the R2 folder.
The downloaded zip file is named with the client's name (e.g. Sarah-Johnson-photos.zip).
Download not starting?
- Check your browser's pop-up blocker. The download opens a new tab, which some browsers may block.
- Open the browser dev tools console and look for 403 or 401 errors. A 403 means the photo key doesn't match the logged-in client's folder.
- Make sure your middleware.ts matcher includes /api/download/:path*. This was added in the previous step.
My download button isn't working and I see errors in the console. Can you help me debug?
Your gallery is feature-complete. Clients can view, browse, and download their photos securely. Next up, you'll tackle the Secret Mission and add photo favoriting so clients can mark their best shots.
Secret mission
Add Photo Favoriting
Your gallery lets clients view and download photos, but what if they want to mark their favorite shots? Add a favoriting system so clients can star the photos they love, filter to see only favorites, and have their selections persist across sessions using localStorage.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and uses Cloudflare R2's free tier for storage. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Next.js dev server (running process).
- Gallery feature files added to your Next.js project (auth.ts, middleware.ts, lib/r2.ts, lib/clients.ts, API routes, gallery page, login page).
- npm packages (next-auth, @aws-sdk/client-s3, @aws-sdk/s3-request-presigner, bcryptjs, @types/bcryptjs).
- .env.local credentials file.
Keep everything running
No action needed. Choose this if you're still actively building your photography studio website and plan to keep working on the gallery feature.
- Your dev server stays running and the gallery remains accessible at /gallery.
- Your .env.local file keeps your R2 credentials available for the next dev session.
- Your Cloudflare R2 bucket and photos remain untouched (these existed before this project).
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files stay intact so you can pick up where you left off.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal where it's running.
- That's it. All your project files, packages, and credentials remain in place.
- To resume later, open the project folder in Cursor and run npm run dev again.
Delete - I don't want to use this again
Remove all the gallery feature code and packages you added during this project. This leaves your original Next.js site intact without the gallery functionality.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal where it's running.
- Remove the gallery feature files by running this command from your project root:
rm -rf auth.ts middleware.ts lib/r2.ts lib/clients.ts app/api/auth app/api/gallery app/api/download app/gallery app/login .env.local
- Uninstall the npm packages you added by running:
npm uninstall next-auth @aws-sdk/client-s3 @aws-sdk/s3-request-presigner bcryptjs @types/bcryptjs
- If you modified app/layout.tsx to add the SessionProvider wrapper, revert it to your original layout.
- Your Cloudflare R2 bucket and photos are not affected. Those existed before this project and remain available for other uses.
Nice Work!
Nice Work!
You just built a fully private client photo gallery for your photography studio website. Your clients can now log in securely, browse their session photos, and download them individually or as a zip file.
You've learned how to:
- Build a credentials-based authentication system using NextAuth v5 with the Next.js App Router to protect routes and API endpoints.
- Integrate Cloudflare R2 via the AWS SDK v3 to serve private images through time-limited presigned URLs.
- Create a responsive photo gallery with lightbox viewing and individual and bulk photo downloads streamed as a zip file.
- Secret Mission: Added a photo favoriting system with localStorage persistence so clients can mark and filter their favorite shots.
Ready to quiz yourself?