Customer Photo Gallery with Next.js

Build a client photo gallery with Cloudflare R2 storage and access codes.

Introduction

30 Second Summary

If you run a photography or creative business, you probably share client photos through Google Drive links or WeTransfer. It works, but it doesn't feel professional or branded.

In this project, you will build a custom photo gallery app where you upload session photos, generate a unique access code, and give clients a polished page to view and download their images.

What You'll Build

A Next.js app with an admin upload page and a customer-facing gallery, powered by Cloudflare R2 cloud storage and deployed to Vercel.

By the end of this project, you'll have:

  • An admin upload page where you batch-upload session photos and generate a unique access code for each client.
  • A customer gallery page where clients enter their code and browse their photos in a responsive grid with individual download buttons.
  • Cloudflare R2 storage configured with zero egress fees, so customer downloads never cost you extra.
  • Secret Mission: Add a "Download All" button that packages all session photos into a single zip file.

Are there any prerequisites?

This project assumes you have an existing Next.js website deployed on Vercel and connected to GitHub. You should also have basic familiarity with Cursor for writing code with AI assistance.

You do not need prior experience with cloud storage or file uploads. The project walks you through everything from scratch.

Before We Start

Before We Start...

Set Up Cloudflare R2 and Create the Project

Your photo gallery needs two things: a place to store customer images that won't rack up bandwidth costs, and a modern web framework to serve them.

In this step, you'll create a storage bucket in Cloudflare R2, then scaffold a Next.js project that connects to it using the AWS S3-compatible SDK.

In this step, get ready to:
  • Create an R2 storage bucket and generate API credentials.
  • Scaffold a Next.js project with TypeScript and Tailwind CSS.
  • Connect the project to R2 with environment variables and a client utility file.
Create your R2 bucket and generate API credentials

Cloudflare R2 is an object storage service that is compatible with the S3 API. It gives you 10 GB of free storage and charges zero egress fees, meaning customers can download as many photos as they want without increasing your bill.

  • Head to your Cloudflare dashboard at dash.cloudflare.com and sign in.
  • In the left sidebar, click Storage & databases.
  • Click R2.
  • Click Create bucket.
  • Enter photo-gallery as the bucket name.
  • Click Create bucket to confirm.

You should see photo-gallery listed as a bucket in your R2 dashboard.

What is Cloudflare R2?

R2 is Cloudflare's object storage service. It works the same way as Amazon S3 (you store files in "buckets" and access them via API), but it charges nothing for data downloads.

For a photo gallery where customers download many large files, zero egress fees keeps your costs at $0 regardless of how popular your gallery gets.

Why R2 instead of Vercel Blob?

Vercel Blob's free tier only includes 1 GB of storage. R2 gives you 10 GB free. More importantly, Vercel Blob charges for data transfer while R2 has zero egress fees.

For a download-heavy photo gallery, this difference means your hosting costs stay at zero instead of scaling with every customer who views their photos.

Now generate an API token so your app can read and write to this bucket.

  • Navigate back to the R2 Overview page.
  • On the right side of the dashboard, click Manage R2 API Tokens.
  • Click Create API token.
  • Under permissions, select Object Read & Write.
  • Click Create API Token.
  • Copy your Access Key ID and Secret Access Key and paste them into a temporary note. You will not be able to see the secret key again after leaving this page.
  • Also copy your Cloudflare Account ID from the same page. Your R2 endpoint URL will be https://your-account-id.r2.cloudflarestorage.com with your actual account ID substituted in.

Can't find your Account ID?

Your Account ID appears on the R2 Overview page under Manage R2 API Tokens on the right side. You can also find it in your browser URL bar when viewing the Cloudflare dashboard. It is the long string after dash.cloudflare.com/.

Help me find my Cloudflare Account ID for R2.

Scaffold the Next.js project

With your storage bucket ready, you need a web application to serve the gallery. You will create a Next.js project with TypeScript for type safety and Tailwind CSS for styling the photo grid.

  • In Cursor, open the terminal by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest photo-gallery

What will the wizard ask?

The create-next-app command launches an interactive wizard that asks about project options. Respond to each prompt individually:

  • Select Yes for TypeScript.
  • Select Yes for ESLint.
  • Select Yes for Tailwind CSS.
  • Select No for the src/ directory.
  • Select Yes for App Router.
  • Accept the default for any remaining prompts by pressing Enter.
  • Move into the project folder by running:
cd photo-gallery
  • Install the AWS SDK packages for connecting to R2 by running:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

What do these packages do?

The @aws-sdk/client-s3 package lets your app talk to any S3-compatible storage service, including Cloudflare R2. It handles uploading, downloading, and managing files in your bucket.

The @aws-sdk/s3-request-presigner package generates temporary URLs that let browsers upload or download files directly from R2 without exposing your credentials.

You should see a success message with the number of packages added.

Install command failing?

Make sure you are inside the photo-gallery folder. Run pwd in your terminal to check your current location. If you do not see photo-gallery at the end of the path, run cd ~/Desktop/photo-gallery first.

Help me fix my npm install error.

Connect the project to Cloudflare R2

Your app needs to know how to reach your R2 bucket. You will store the credentials in environment variables and create a utility file that initializes the S3 client.

  • In the Cursor file explorer sidebar, right-click in the root of your photo-gallery folder and select New File.
  • Name the file .env.local.
  • Paste the following into .env.local, replacing the placeholder values with your actual R2 credentials from earlier:
R2_ACCESS_KEY_ID=your-access-key-id-here
R2_SECRET_ACCESS_KEY=your-secret-access-key-here
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
R2_BUCKET_NAME=photo-gallery

What does each variable do?

  • R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY authenticate your app with Cloudflare.
  • R2_ENDPOINT tells the SDK where to send requests. Replace your-account-id with the Account ID you copied earlier.
  • R2_BUCKET_NAME is the name of the bucket you just created.
  • Replace your-access-key-id-here with your actual Access Key ID.
  • Replace your-secret-access-key-here with your actual Secret Access Key.
  • Replace your-account-id in the endpoint URL with your actual Cloudflare Account ID.
  • Save .env.local.

Now create the R2 client utility file that your API routes will use.

  • In the file explorer, create a new folder called lib in the root of your photo-gallery project.
  • Inside the lib folder, create a new file called r2.ts.
  • Paste the following code into lib/r2.ts:
import { S3Client } from "@aws-sdk/client-s3";

// Initialize the S3-compatible client pointing at your R2 bucket
export const r2Client = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT!,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

// Export the bucket name for use across API routes
export const BUCKET_NAME = process.env.R2_BUCKET_NAME!;

What does this code do?

  • S3Client is the AWS SDK class that handles all communication with S3-compatible services like R2.
  • region: "auto" is required by the SDK but R2 automatically routes to the closest region.
  • The ! after each process.env value tells TypeScript these variables will always be defined at runtime.
  • Exporting r2Client and BUCKET_NAME means every API route can import these instead of repeating the setup.
  • Save lib/r2.ts.
  • Verify everything works by starting the development server:
npm run dev

You should see output indicating the server is running at localhost:3000. Open that URL in your browser to confirm you see the default Next.js welcome page.

Server not starting?

If you see an error about missing modules, make sure you ran npm install from inside the photo-gallery folder. Also confirm your .env.local file is in the project root (not inside lib/ or app/).

Help me fix my Next.js dev server startup error.

Set up CORS for later

When you test browser uploads in the next step, R2 requires a CORS policy on the bucket. You can configure this in the Cloudflare dashboard under your bucket's Settings tab if you encounter upload errors. The policy needs to allow PUT and GET methods from your app's origin.

✔️ Awesome, I've got everything!

Great. Double check you have saved both .env.local and lib/r2.ts before moving on.

ⓧ I'd like to double check the full code

Here is what your .env.local should look like (with your real values substituted in):

R2_ACCESS_KEY_ID=your-access-key-id-here
R2_SECRET_ACCESS_KEY=your-secret-access-key-here
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
R2_BUCKET_NAME=photo-gallery

And here is the full lib/r2.ts:

import { S3Client } from "@aws-sdk/client-s3";

export const r2Client = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT!,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

export const BUCKET_NAME = process.env.R2_BUCKET_NAME!;

Your storage bucket is live and your project is wired up to it. Next up, you will build the admin upload page that lets you send photos directly to R2.

Build the Admin Upload Page

Your R2 bucket is ready and your app can connect to it. Now you need to build the interface that actually uploads photos and organizes them into client sessions.

In this step, you will create two API routes and one front-end page. The first API route generates presigned URLs so photos upload directly from the browser to R2 without passing through your server. The second API route saves session metadata (client name, photo list, access code) as a JSON file in R2. The admin page ties them together with a file picker, progress bar, and access code display.

In this step, get ready to:
  • Create a presigned upload API route that returns a temporary upload URL for each photo.
  • Create a sessions API route that generates a 6-character access code and stores session metadata in R2.
  • Build the admin upload page with multi-file selection, a progress bar, and access code output.
Create the presigned upload API route

When a user selects photos, the browser needs a secure way to upload them directly to R2. Your server generates a short-lived presigned URL for each file, and the browser uses that URL to PUT the file straight into your bucket.

  • In Cursor, create a new folder path app/api/upload/ inside your photo-gallery project.
  • Create a file called route.ts inside that folder, then paste the following code:
import { NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { r2Client, BUCKET_NAME } from "@/lib/r2";

export async function POST(request: Request) {
  const { filename, contentType, sessionId } = await request.json();

  if (!filename || !contentType || !sessionId) {
    return NextResponse.json({ error: "Missing fields" }, { status: 400 });
  }

  // Build a unique key so filenames never collide
  const key = `sessions/${sessionId}/${crypto.randomUUID()}-${filename}`;

  // Create the PutObject command with the expected content type
  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });

  // Generate a presigned URL valid for 10 minutes
  const presignedUrl = await getSignedUrl(r2Client, command, {
    expiresIn: 600,
  });

  return NextResponse.json({ presignedUrl, key });
}

What does this code do?

This API route accepts a POST request with a filename, contentType, and sessionId. It builds a unique storage key by combining the session ID, a random UUID, and the original filename.

The getSignedUrl function creates a URL that lets anyone upload one file to that exact key within 10 minutes. The file never passes through your Next.js server, which avoids Vercel's serverless payload limits.

  • Save app/api/upload/route.ts.

Why presigned URLs instead of server uploads?

Presigned URLs let the browser upload files directly to R2. Your server only generates the URL. This means large photos never hit your Next.js server, keeping serverless function execution fast and avoiding payload size limits.

Create the sessions API route

After all photos are uploaded, you need to bundle them into a "session" with a unique access code. This route generates that code, saves the session data as a JSON file in R2, and returns the code to display to the admin.

  • Create a new folder path app/api/sessions/ inside your project.
  • Create a file called route.ts inside that folder, then paste this code:
import { NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { r2Client, BUCKET_NAME } from "@/lib/r2";

// Generate a 6-character code excluding confusing characters (0/O, 1/I/L)
function generateCode(): string {
  const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
  let code = "";
  for (let i = 0; i < 6; i++) {
    code += chars[Math.floor(Math.random() * chars.length)];
  }
  return code;
}

export async function POST(request: Request) {
  const { clientName, photoKeys } = await request.json();

  if (!clientName || !photoKeys || photoKeys.length === 0) {
    return NextResponse.json({ error: "Missing fields" }, { status: 400 });
  }

  const code = generateCode();

  // Bundle the session metadata
  const sessionData = {
    code,
    clientName,
    photoKeys,
    createdAt: new Date().toISOString(),
  };

  // Store the session JSON in R2 under metadata/{code}.json
  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: `metadata/${code}.json`,
    Body: JSON.stringify(sessionData),
    ContentType: "application/json",
  });

  await r2Client.send(command);

  return NextResponse.json({ code });
}

What does this code do?

The generateCode function picks 6 random characters from a set that excludes easily confused characters like O/0 and I/1/L. This makes the code easy for clients to type correctly.

The POST handler stores the session metadata (access code, client name, photo keys, and timestamp) as a JSON file at metadata/{code}.json in R2. No database needed. Later, the gallery page reads this file to know which photos belong to the session.

  • Save app/api/sessions/route.ts.

Seeing a module not found error?

Make sure your import path is @/lib/r2 (with the @ alias). If you see an error about the module not being found, confirm that your lib/r2.ts file is at the project root level (not inside app/).

help me debug a module not found error for @/lib/r2

Build the admin upload page

Now you will create the front-end page that ties the two API routes together. This page lets you select multiple photos, enter a client name, upload everything to R2 with a live progress bar, and display the generated access code.

  • Create a new folder app/admin/ inside your project.
  • Create a file called page.tsx inside that folder, then paste the following code:
"use client";

import { useState } from "react";

export default function AdminPage() {
  const [clientName, setClientName] = useState("");
  const [files, setFiles] = useState<FileList | null>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [accessCode, setAccessCode] = useState("");

  async function handleUpload() {
    if (!files || !clientName) return;

    setUploading(true);
    setProgress(0);

    // Create a unique session ID for this upload batch
    const sessionId = crypto.randomUUID();
    const photoKeys: string[] = [];

    // Upload each file using a presigned URL
    for (let i = 0; i < files.length; i++) {
      const file = files[i];

      // Request a presigned URL from our API
      const res = await fetch("/api/upload", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
          sessionId,
        }),
      });

      const { presignedUrl, key } = await res.json();

      // Upload the file directly to R2
      await fetch(presignedUrl, {
        method: "PUT",
        body: file,
        headers: { "Content-Type": file.type },
      });

      photoKeys.push(key);
      setProgress(Math.round(((i + 1) / files.length) * 100));
    }

    // Save the session with all photo keys
    const sessionRes = await fetch("/api/sessions", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ clientName, photoKeys }),
    });

    const { code } = await sessionRes.json();
    setAccessCode(code);
    setUploading(false);
  }

  return (
    <main className="min-h-screen p-8 max-w-2xl mx-auto">
      <h1 className="text-3xl font-bold mb-8">Upload Session Photos</h1>

      {accessCode ? (
        <div className="bg-green-50 border border-green-200 rounded-lg p-6">
          <p className="text-lg font-semibold text-green-800">Upload Complete!</p>
          <p className="mt-2 text-green-700">
            Access code for {clientName}:
          </p>
          <p className="text-4xl font-mono font-bold mt-2 text-green-900">
            {accessCode}
          </p>
          <button
            onClick={() => {
              setAccessCode("");
              setClientName("");
              setFiles(null);
              setProgress(0);
            }}
            className="mt-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
          >
            Upload Another Session
          </button>
        </div>
      ) : (
        <div className="space-y-6">
          <div>
            <label className="block text-sm font-medium mb-2">Client Name</label>
            <input
              type="text"
              value={clientName}
              onChange={(e) => setClientName(e.target.value)}
              className="w-full px-4 py-2 border rounded-lg"
              placeholder="e.g. Sarah Johnson"
            />
          </div>

          <div>
            <label className="block text-sm font-medium mb-2">Session Photos</label>
            <input
              type="file"
              multiple
              accept="image/*"
              onChange={(e) => setFiles(e.target.files)}
              className="w-full"
            />
            {files && (
              <p className="mt-2 text-sm text-gray-600">
                {files.length} photo{files.length !== 1 ? "s" : ""} selected
              </p>
            )}
          </div>

          {uploading && (
            <div className="w-full bg-gray-200 rounded-full h-3">
              <div
                className="bg-blue-600 h-3 rounded-full transition-all"
                style={{ width: `${progress}%` }}
              />
            </div>
          )}

          <button
            onClick={handleUpload}
            disabled={uploading || !files || !clientName}
            className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            {uploading ? `Uploading... ${progress}%` : "Upload Photos"}
          </button>
        </div>
      )}
    </main>
  );
}

What does this code do?

The handleUpload function loops through each selected file. For every file, it asks your upload API for a presigned URL, then PUTs the file directly to R2. It tracks progress as a percentage so the progress bar fills in real time.

Once all files are uploaded, it calls the sessions API with the client name and list of photo keys. The sessions API returns the 6-character access code, which the page displays in a large green success banner.

The "use client" directive at the top tells Next.js this is a client component that uses browser features like useState and fetch.

  • Save app/admin/page.tsx.
  • Return to your browser and navigate to http://localhost:3000/admin.
  • You should see a page titled "Upload Session Photos" with a client name field, a file picker, and an upload button.

Configure CORS on your R2 bucket

Before testing uploads, you need to add a CORS policy to your R2 bucket. Without it, browser uploads to presigned URLs will fail because the browser enforces cross-origin restrictions.

  • In the Cloudflare dashboard, go to **Storage & databases**, then **R2**, then select your **photo-gallery** bucket.
  • Click **Settings**, scroll to the **CORS policy** section, and click **Add CORS policy**.
  • Paste the following JSON policy:
[
  {
    "AllowedOrigins": ["http://localhost:3000"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedHeaders": ["Content-Type"],
    "MaxAgeSeconds": 3600
  }
]

Save the policy. When you deploy later, add your production URL to AllowedOrigins as well.

  • Test the full flow: enter a client name, select 2-3 photos, and click **Upload Photos**.
  • You should see the progress bar fill up, then a green banner showing a 6-character access code.

Upload failing or hanging?

If the upload fails immediately, check that your .env.local has the correct R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_ENDPOINT values. A typo in the endpoint URL is the most common cause.

If you see a CORS error in the browser console, make sure your R2 bucket CORS policy includes http://localhost:3000 in AllowedOrigins and PUT in AllowedMethods.

help me debug a failed upload to Cloudflare R2 using presigned URLs

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file created in this step.

import { NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { r2Client, BUCKET_NAME } from "@/lib/r2";

export async function POST(request: Request) {
  const { filename, contentType, sessionId } = await request.json();

  if (!filename || !contentType || !sessionId) {
    return NextResponse.json({ error: "Missing fields" }, { status: 400 });
  }

  const key = `sessions/${sessionId}/${crypto.randomUUID()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });

  const presignedUrl = await getSignedUrl(r2Client, command, {
    expiresIn: 600,
  });

  return NextResponse.json({ presignedUrl, key });
}
import { NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { r2Client, BUCKET_NAME } from "@/lib/r2";

function generateCode(): string {
  const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
  let code = "";
  for (let i = 0; i < 6; i++) {
    code += chars[Math.floor(Math.random() * chars.length)];
  }
  return code;
}

export async function POST(request: Request) {
  const { clientName, photoKeys } = await request.json();

  if (!clientName || !photoKeys || photoKeys.length === 0) {
    return NextResponse.json({ error: "Missing fields" }, { status: 400 });
  }

  const code = generateCode();

  const sessionData = {
    code,
    clientName,
    photoKeys,
    createdAt: new Date().toISOString(),
  };

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: `metadata/${code}.json`,
    Body: JSON.stringify(sessionData),
    ContentType: "application/json",
  });

  await r2Client.send(command);

  return NextResponse.json({ code });
}
"use client";

import { useState } from "react";

export default function AdminPage() {
  const [clientName, setClientName] = useState("");
  const [files, setFiles] = useState<FileList | null>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [accessCode, setAccessCode] = useState("");

  async function handleUpload() {
    if (!files || !clientName) return;

    setUploading(true);
    setProgress(0);

    const sessionId = crypto.randomUUID();
    const photoKeys: string[] = [];

    for (let i = 0; i < files.length; i++) {
      const file = files[i];

      const res = await fetch("/api/upload", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
          sessionId,
        }),
      });

      const { presignedUrl, key } = await res.json();

      await fetch(presignedUrl, {
        method: "PUT",
        body: file,
        headers: { "Content-Type": file.type },
      });

      photoKeys.push(key);
      setProgress(Math.round(((i + 1) / files.length) * 100));
    }

    const sessionRes = await fetch("/api/sessions", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ clientName, photoKeys }),
    });

    const { code } = await sessionRes.json();
    setAccessCode(code);
    setUploading(false);
  }

  return (
    <main className="min-h-screen p-8 max-w-2xl mx-auto">
      <h1 className="text-3xl font-bold mb-8">Upload Session Photos</h1>

      {accessCode ? (
        <div className="bg-green-50 border border-green-200 rounded-lg p-6">
          <p className="text-lg font-semibold text-green-800">Upload Complete!</p>
          <p className="mt-2 text-green-700">Access code for {clientName}:</p>
          <p className="text-4xl font-mono font-bold mt-2 text-green-900">{accessCode}</p>
          <button
            onClick={() => { setAccessCode(""); setClientName(""); setFiles(null); setProgress(0); }}
            className="mt-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
          >Upload Another Session</button>
        </div>
      ) : (
        <div className="space-y-6">
          <div>
            <label className="block text-sm font-medium mb-2">Client Name</label>
            <input type="text" value={clientName} onChange={(e) => setClientName(e.target.value)} className="w-full px-4 py-2 border rounded-lg" placeholder="e.g. Sarah Johnson" />
          </div>
          <div>
            <label className="block text-sm font-medium mb-2">Session Photos</label>
            <input type="file" multiple accept="image/*" onChange={(e) => setFiles(e.target.files)} className="w-full" />
            {files && <p className="mt-2 text-sm text-gray-600">{files.length} photo{files.length !== 1 ? "s" : ""} selected</p>}
          </div>
          {uploading && (
            <div className="w-full bg-gray-200 rounded-full h-3">
              <div className="bg-blue-600 h-3 rounded-full transition-all" style={{ width: `${progress}%` }} />
            </div>
          )}
          <button onClick={handleUpload} disabled={uploading || !files || !clientName} className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
            {uploading ? `Uploading... ${progress}%` : "Upload Photos"}
          </button>
        </div>
      )}
    </main>
  );
}

Your admin upload flow is working. You can upload batches of photos and get a unique code for each session. Next up, you will build the customer-facing gallery where clients enter their code and browse their photos.

Build the Customer Gallery Page

Your admin upload page is working. Customers now need a way to access their photos using the 6-character code you generated.

In this step, you'll build the customer-facing side of the gallery. This includes an entry page where clients type their access code, an API route that fetches session data and generates temporary download links, and a responsive photo grid with individual download buttons.

In this step, get ready to:
  • Create a gallery entry page with a code input form.
  • Build an API route that retrieves session data and generates presigned read URLs.
  • Build a responsive photo grid with download buttons.
Create the gallery entry page

Customers need a simple, clean page where they paste the 6-character access code they received from you. This page validates the code and redirects them to their photo gallery.

  • In Cursor, create a new file at app/gallery/page.tsx.
  • You will need to create the gallery folder inside app/ first.
  • Paste the following code into app/gallery/page.tsx:
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export default function GalleryEntryPage() {
  const [code, setCode] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");

    const cleanCode = code.trim().toUpperCase();
    if (cleanCode.length !== 6) {
      setError("Please enter a 6-character code.");
      return;
    }

    const res = await fetch(`/api/gallery/${cleanCode}`);
    if (!res.ok) {
      setError("Invalid code. Please check and try again.");
      return;
    }

    router.push(`/gallery/${cleanCode}`);
  }

  return (
    <main className="min-h-screen flex items-center justify-center p-8">
      <div className="max-w-md w-full">
        <h1 className="text-3xl font-bold text-center mb-2">View My Photos</h1>
        <p className="text-gray-600 text-center mb-8">
          Enter the access code you received to view your session photos.
        </p>

        <form onSubmit={handleSubmit} className="space-y-4">
          <input
            type="text"
            value={code}
            onChange={(e) => setCode(e.target.value.toUpperCase())}
            maxLength={6}
            className="w-full px-4 py-3 text-center text-2xl font-mono tracking-widest border rounded-lg uppercase"
            placeholder="ABC123"
          />

          {error && <p className="text-red-600 text-sm text-center">{error}</p>}

          <button
            type="submit"
            className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
          >
            View Photos
          </button>
        </form>
      </div>
    </main>
  );
}

What does this code do?

This page shows a centered form with a single large text input. It auto-capitalizes whatever the customer types so the code always matches.

  • handleSubmit trims whitespace, checks the code is exactly 6 characters, and validates it against the API before navigating.
  • If the API returns an error (code not found), the page shows an error message without navigating away.
  • useRouter from Next.js handles the client-side navigation to the gallery view at /gallery/[code].
  • Save app/gallery/page.tsx.
  • In your browser, navigate to http://localhost:3000/gallery. You should see a centered "View My Photos" heading with a large code input field and a blue "View Photos" button.

Don't see the gallery page?

  • Make sure the file is saved at app/gallery/page.tsx (not app/gallery/page.ts or inside the wrong folder).
  • Check that your dev server is still running. If it stopped, run npm run dev again in your terminal.

Still stuck? Help me debug why my gallery page isn't showing at /gallery

Create the gallery API route

The entry page validates the code by calling an API route. This route reads the session JSON from Cloudflare R2 and generates temporary presigned URLs so the browser can fetch each photo directly from storage.

  • Create the folder path app/api/gallery/[code]/ inside your project. The square brackets in [code] tell Next.js this is a dynamic route segment.
  • Create a new file at app/api/gallery/[code]/route.ts and paste the following code:
import { NextResponse } from "next/server";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { r2Client, BUCKET_NAME } from "@/lib/r2";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ code: string }> }
) {
  const { code } = await params;

  try {
    // Fetch the session metadata JSON from R2
    const metadataCommand = new GetObjectCommand({
      Bucket: BUCKET_NAME,
      Key: `metadata/${code}.json`,
    });

    const response = await r2Client.send(metadataCommand);
    const bodyString = await response.Body!.transformToString();
    const session = JSON.parse(bodyString);

    // Generate a temporary presigned URL for each photo
    const photoUrls = await Promise.all(
      session.photoKeys.map(async (key: string) => {
        const url = await getSignedUrl(
          r2Client,
          new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }),
          { expiresIn: 3600 }
        );
        return { key, url };
      })
    );

    return NextResponse.json({
      clientName: session.clientName,
      photos: photoUrls,
    });
  } catch {
    return NextResponse.json({ error: "Session not found" }, { status: 404 });
  }
}

What does this code do?

  • The route extracts the code from the URL (e.g. /api/gallery/ABC123) and fetches the matching JSON file from R2.
  • For each photo key stored in the session, it generates a presigned GET URL that expires after 1 hour (3600 seconds). The customer's browser uses these URLs to load photos directly from R2.
  • If the code doesn't match any session file, the route returns a 404 error. This is what triggers the "Invalid code" message on the entry page.

Why presigned URLs instead of making the bucket public?

A public bucket would let anyone with the URL access any photo. Presigned URLs are time-limited and scoped to a single file. Only customers who enter a valid access code get working URLs, and those URLs expire after an hour.

  • Save app/api/gallery/[code]/route.ts.
  • Return to your browser at http://localhost:3000/gallery and try entering a random code like XXXXXX. You should see "Invalid code. Please check and try again." appear below the input.

Seeing a server error instead of the invalid code message?

  • Check that the folder is named exactly [code] with square brackets. This is how Next.js identifies dynamic route segments.
  • Confirm the file is named route.ts (not route.tsx). API routes don't need JSX.
  • Make sure your .env.local credentials are correct. The route needs to reach R2 to check if the session exists.

Need more help? Help me debug my gallery API route

Build the photo grid display page

The final piece is the gallery view itself. When a customer enters a valid code, they land on a page that loads all their photos in a responsive grid with download buttons.

  • Create a new file at app/gallery/[code]/page.tsx. This uses the same [code] dynamic segment pattern.
  • Paste the following code into the file:
"use client";

import { useEffect, useState } from "react";
import { useParams } from "next/navigation";

interface Photo {
  key: string;
  url: string;
}

interface SessionData {
  clientName: string;
  photos: Photo[];
}

export default function GalleryPage() {
  const params = useParams();
  const code = params.code as string;
  const [session, setSession] = useState<SessionData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  useEffect(() => {
    async function loadGallery() {
      const res = await fetch(`/api/gallery/${code}`);
      if (!res.ok) {
        setError("Session not found.");
        setLoading(false);
        return;
      }
      const data = await res.json();
      setSession(data);
      setLoading(false);
    }
    loadGallery();
  }, [code]);

  if (loading) {
    return (
      <main className="min-h-screen flex items-center justify-center">
        <p className="text-lg">Loading your photos...</p>
      </main>
    );
  }

  if (error || !session) {
    return (
      <main className="min-h-screen flex items-center justify-center">
        <p className="text-lg text-red-600">{error}</p>
      </main>
    );
  }

  // Download a photo by fetching it as a blob and triggering a browser download
  async function downloadPhoto(url: string, filename: string) {
    const response = await fetch(url);
    const blob = await response.blob();
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = filename;
    link.click();
    URL.revokeObjectURL(link.href);
  }

  return (
    <main className="min-h-screen p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold mb-2">{session.clientName}</h1>
        <p className="text-gray-600 mb-8">
          {session.photos.length} photo{session.photos.length !== 1 ? "s" : ""}
        </p>

        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
          {session.photos.map((photo, index) => {
            const filename = photo.key.split("/").pop() || `photo-${index}.jpg`;
            return (
              <div key={photo.key} className="relative group">
                <img
                  src={photo.url}
                  alt={`Photo ${index + 1}`}
                  className="w-full aspect-square object-cover rounded-lg"
                />
                <button
                  onClick={() => downloadPhoto(photo.url, filename)}
                  className="absolute bottom-2 right-2 px-3 py-1 bg-white/90 text-sm rounded shadow opacity-0 group-hover:opacity-100 transition-opacity"
                >
                  Download
                </button>
              </div>
            );
          })}
        </div>
      </div>
    </main>
  );
}

What does this code do?

  • useParams() grabs the access code from the URL. On page load, useEffect calls the gallery API route to fetch the session's photo URLs.
  • The grid uses Tailwind responsive classes: 2 columns on mobile, 3 on medium screens, 4 on large screens.
  • Each photo has a "Download" button that appears on hover. The downloadPhoto function fetches the image as a blob, creates a temporary object URL, and triggers a file download in the browser.
  • While photos are loading, the page shows a "Loading your photos..." message. If something goes wrong, an error message appears instead.
  • Save app/gallery/[code]/page.tsx.
  • Test the full customer flow: go to http://localhost:3000/admin, upload a few photos, and copy the access code.
  • Navigate to http://localhost:3000/gallery and enter the code. You should see your photos in a responsive grid with download buttons that appear when you hover over each image.

Photos not loading in the grid?

  • Open your browser's DevTools console and check for CORS errors. If you see a CORS error, you need to configure CORS on your R2 bucket in the Cloudflare dashboard.
  • Verify that the access code you entered matches one from a successful upload. Codes are case-insensitive (the page auto-capitalizes).
  • If you see "Session not found" for a code you just created, double-check that your .env.local credentials point to the same bucket where the upload went.

Need help? Help me debug why my gallery photos aren't loading

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three new files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each of the three files you created in this step.

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export default function GalleryEntryPage() {
  const [code, setCode] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");

    const cleanCode = code.trim().toUpperCase();
    if (cleanCode.length !== 6) {
      setError("Please enter a 6-character code.");
      return;
    }

    const res = await fetch(`/api/gallery/${cleanCode}`);
    if (!res.ok) {
      setError("Invalid code. Please check and try again.");
      return;
    }

    router.push(`/gallery/${cleanCode}`);
  }

  return (
    <main className="min-h-screen flex items-center justify-center p-8">
      <div className="max-w-md w-full">
        <h1 className="text-3xl font-bold text-center mb-2">View My Photos</h1>
        <p className="text-gray-600 text-center mb-8">
          Enter the access code you received to view your session photos.
        </p>

        <form onSubmit={handleSubmit} className="space-y-4">
          <input
            type="text"
            value={code}
            onChange={(e) => setCode(e.target.value.toUpperCase())}
            maxLength={6}
            className="w-full px-4 py-3 text-center text-2xl font-mono tracking-widest border rounded-lg uppercase"
            placeholder="ABC123"
          />

          {error && <p className="text-red-600 text-sm text-center">{error}</p>}

          <button
            type="submit"
            className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
          >
            View Photos
          </button>
        </form>
      </div>
    </main>
  );
}
import { NextResponse } from "next/server";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { r2Client, BUCKET_NAME } from "@/lib/r2";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ code: string }> }
) {
  const { code } = await params;

  try {
    const metadataCommand = new GetObjectCommand({
      Bucket: BUCKET_NAME,
      Key: `metadata/${code}.json`,
    });

    const response = await r2Client.send(metadataCommand);
    const bodyString = await response.Body!.transformToString();
    const session = JSON.parse(bodyString);

    const photoUrls = await Promise.all(
      session.photoKeys.map(async (key: string) => {
        const url = await getSignedUrl(
          r2Client,
          new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }),
          { expiresIn: 3600 }
        );
        return { key, url };
      })
    );

    return NextResponse.json({
      clientName: session.clientName,
      photos: photoUrls,
    });
  } catch {
    return NextResponse.json({ error: "Session not found" }, { status: 404 });
  }
}
"use client";

import { useEffect, useState } from "react";
import { useParams } from "next/navigation";

interface Photo {
  key: string;
  url: string;
}

interface SessionData {
  clientName: string;
  photos: Photo[];
}

export default function GalleryPage() {
  const params = useParams();
  const code = params.code as string;
  const [session, setSession] = useState<SessionData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  useEffect(() => {
    async function loadGallery() {
      const res = await fetch(`/api/gallery/${code}`);
      if (!res.ok) {
        setError("Session not found.");
        setLoading(false);
        return;
      }
      const data = await res.json();
      setSession(data);
      setLoading(false);
    }
    loadGallery();
  }, [code]);

  if (loading) {
    return (
      <main className="min-h-screen flex items-center justify-center">
        <p className="text-lg">Loading your photos...</p>
      </main>
    );
  }

  if (error || !session) {
    return (
      <main className="min-h-screen flex items-center justify-center">
        <p className="text-lg text-red-600">{error}</p>
      </main>
    );
  }

  async function downloadPhoto(url: string, filename: string) {
    const response = await fetch(url);
    const blob = await response.blob();
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = filename;
    link.click();
    URL.revokeObjectURL(link.href);
  }

  return (
    <main className="min-h-screen p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold mb-2">{session.clientName}</h1>
        <p className="text-gray-600 mb-8">
          {session.photos.length} photo{session.photos.length !== 1 ? "s" : ""}
        </p>

        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
          {session.photos.map((photo, index) => {
            const filename = photo.key.split("/").pop() || `photo-${index}.jpg`;
            return (
              <div key={photo.key} className="relative group">
                <img
                  src={photo.url}
                  alt={`Photo ${index + 1}`}
                  className="w-full aspect-square object-cover rounded-lg"
                />
                <button
                  onClick={() => downloadPhoto(photo.url, filename)}
                  className="absolute bottom-2 right-2 px-3 py-1 bg-white/90 text-sm rounded shadow opacity-0 group-hover:opacity-100 transition-opacity"
                >
                  Download
                </button>
              </div>
            );
          })}
        </div>
      </div>
    </main>
  );
}

The full customer flow is working locally. Your clients can now enter their access code and browse their photos in a clean, responsive grid. Next up, you'll deploy this app to Vercel so your customers can access their gallery from anywhere.

Deploy to Vercel

Your photo gallery works perfectly on your local machine. Now it's time to put it live so your customers can actually use it.

In this step, you'll push your code to GitHub and deploy it on Vercel. Vercel connects directly to your GitHub repository, so every future push automatically redeploys your app.

In this step, get ready to:
  • Push your code to a new GitHub repository.
  • Deploy on Vercel with your R2 environment variables.
  • Test the full end-to-end flow on the live URL.
Push your code to GitHub

Your app is ready to go live. The first step is getting your code into a GitHub repository so Vercel can pull it for deployment.

  • Switch back to the terminal in Cursor.
  • Initialize a git repository and commit all your code by running these commands:
git init
git add .
git commit -m "Initial commit"

What about my .env.local file?

Next.js automatically includes .env.local in its .gitignore file. Your R2 credentials will not be committed to GitHub.

You'll add these values to Vercel separately in the next substep.

  • Open your browser and go to github.com.
  • Click the + icon in the top-right corner, then select New repository.
  • Enter photo-gallery as the repository name.
  • Leave the repository set to Public.
  • Do not check any of the boxes for README, .gitignore, or license. Your project already has these.
  • Click Create repository.

GitHub shows a page with setup commands. You'll use the commands for pushing an existing repository.

  • Copy the repository URL from the page (it looks like https://github.com/your-username/photo-gallery.git).
  • Back in Cursor's terminal, connect your local repo to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/your-username/photo-gallery.git"]]
git branch -M main
git push -u origin main
  • Refresh the GitHub page in your browser. You should see all your project files listed in the repository.

Push failed or permission denied?

Make sure you're signed into the correct GitHub account in your terminal. If you see an authentication error, GitHub may need you to set up a personal access token or SSH key.

Help me fix this push error

Import to Vercel and add environment variables

Vercel can import your GitHub repository directly. It will detect that your project uses Next.js and configure the build automatically.

  • Go to vercel.com/new in your browser.
  • Find the photo-gallery repository in the list and click Import.

Don't see your repository?

If this is your first time using Vercel, you may need to connect your GitHub account. Click Add GitHub Account and grant Vercel access to your repositories.

Before deploying, you need to add your R2 credentials as environment variables. Vercel needs these to connect to your Cloudflare R2 bucket at runtime.

  • On the import page, expand the Environment Variables section.
  • Add the following four variables, using the same values from your .env.local file:
  • Name: R2_ACCESS_KEY_ID with your access key ID value.
  • Name: R2_SECRET_ACCESS_KEY with your secret access key value.
  • Name: R2_ENDPOINT with your endpoint URL (e.g. https://your-account-id.r2.cloudflarestorage.com).
  • Name: R2_BUCKET_NAME with the value photo-gallery.
  • For each variable, type the name in the Key field, paste the value in the Value field, then click Add.
  • After all four are added, click Deploy.

Vercel will build and deploy your app. This takes about 30-60 seconds.

  • Wait for the build to complete. You should see a congratulations screen with a preview of your deployed site.

Build failed?

Check the build logs for errors. Common causes include missing environment variables (double-check you added all four) or a typo in the variable names. The names must match exactly: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME.

Help me debug this build failure

Test the deployed app and update your website

Your gallery is now live on the internet. Time to test the full flow as both an admin and a customer.

  • Click Continue to Dashboard on the congratulations screen, then click Visit to open your deployed app.
  • Copy your app's URL from the browser address bar (it looks like photo-gallery-xxxx.vercel.app).
  • Navigate to the admin page by adding /admin to your app URL.
  • Upload 2-3 test photos with a client name.
  • Copy the 6-character access code you receive.
  • Open a new browser tab and go to your app URL with /gallery appended.
  • Enter the access code and verify that your photos appear in the grid.
  • Click Download on one of the photos to confirm it saves to your computer.

Uploads failing on the deployed site?

If uploads worked locally but fail on the deployed version, you likely need to configure CORS on your R2 bucket. Browser-based uploads to a presigned URL on a different origin require a CORS policy.

To fix this:

  • In the Cloudflare dashboard, go to your R2 bucket.
  • Select Settings.
  • Under CORS Policy, click Add CORS policy.
  • Paste this JSON policy, replacing the origin with your Vercel URL:
[
  {
    "AllowedOrigins": ["https://your-app-name.vercel.app"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]
  • Click Save, then retry your upload.

Help me fix this CORS error

Now that your gallery is live, you can link it from your existing website so customers can find it.

  • Open your existing website project in Cursor.
  • Find the button or link where you want customers to access their photos (for example, a "View My Photos" button).
  • Update the link's URL to point to your new gallery page. Use your deployed app's URL followed by /gallery (e.g. https://photo-gallery-xxxx.vercel.app/gallery).
  • Commit and push that change so your main website redeploys with the updated link.

Why a separate app instead of adding pages to my existing site?

Keeping the gallery as its own app means you can update it without risking your main site. If something breaks in the gallery code, your main business website stays online.

You can always merge them later once you're more comfortable with the codebase.

Your photo gallery is live and working. Customers can now enter their access code and view their session photos from anywhere. Next up, you'll take on the Secret Mission and add a "Download All" button that packages every photo into a zip file.

Secret mission

Add "Download All" as Zip

Your customers can download individual photos, but what about grabbing the whole session at once? Add a "Download All Photos" button that packages every image into a single zip file for one-click convenience.

Clean Up Your Resources

Clean Up Your Resources

This project uses Cloudflare R2's free tier (10 GB storage, zero egress fees) and Vercel's free Hobby tier. There are no ongoing costs within these limits.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Cloudflare R2 bucket named photo-gallery (free tier: 10 GB storage, zero egress).
  • Vercel project deployment (free Hobby tier).
  • GitHub repository containing your project code.
  • Local photo-gallery project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or using the gallery to share photos with clients.

  • Your R2 bucket will continue storing photos at no cost within the 10 GB free tier.
  • Your Vercel deployment stays live on the free Hobby tier.
  • Old sessions will accumulate in R2 storage over time. Consider deleting old session photos periodically to stay within the 10 GB limit.

Pause - I'll come back to this later

Since both Cloudflare R2 and Vercel are on free tiers, there is nothing to pause. Your app and storage will remain available at no cost.

  • Stop the local development server if it is still running by pressing Ctrl+C in your terminal.
  • Your deployed app on Vercel remains accessible without any charges.
  • Your R2 bucket keeps your uploaded photos safely stored at no cost.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to delete everything you created.

  • Delete the R2 bucket:
  • Delete the Vercel project:
  • Delete the GitHub repository:
  • Delete the local project folder:
cd ~/Desktop
rm -rf photo-gallery

On Windows, run this instead:

cd ~\Desktop
Remove-Item -Recurse -Force photo-gallery

Nice Work!

Nice Work!

Nice work! You just built and deployed a professional customer photo gallery that replaces clunky Google Drive links with a branded, code-protected viewing experience.

You've learned how to:

  • Build a Next.js app with an admin upload page and a customer-facing gallery, using presigned URLs so files never pass through your server.
  • Configure Cloudflare R2 as an S3-compatible object store with zero egress fees, storing both photos and session metadata in a single bucket.
  • Deploy a production app to Vercel connected to cloud storage, with environment variables securely configured for your R2 credentials.
  • Secret Mission: Added a "Download All" zip feature using archiver so customers can grab their entire session in one click.

Ready to quiz yourself?