Building a Modern Image Gallery with Next.js 16, TypeScript & Unsplash API

Published: (December 11, 2025 at 03:59 PM EST)
4 min read
Source: Dev.to

Source: Dev.to

🛠 Tech Stack & Features

Core Technologies

  • Next.js 16 – App Router with Server Components
  • TypeScript – Full type safety
  • Tailwind CSS v4 – Utility‑first styling with CSS variables
  • Unsplash API – High‑quality image source
  • Lucide React – Modern icon library

Key Features

  • ✅ Server‑Side Rendering (SSR) for the initial page load
  • ✅ Static Site Generation (SSG) for popular photos
  • ✅ Dynamic routing with /photos/[id]
  • ✅ Image optimization with next/image
  • ✅ Real‑time search with debouncing
  • ✅ Category filtering (Nature, Architecture, etc.)
  • ✅ Dark mode with system‑preference detection
  • ✅ Responsive, mobile‑first design
  • ✅ SEO‑optimized dynamic metadata
  • ✅ Error boundaries and loading states

🏗 Project Architecture

File Structure

next-image-gallery/
├── app/
│   ├── layout.tsx           # Root layout with ThemeProvider
│   ├── page.tsx             # Home page (Server Component)
│   ├── loading.tsx          # Global loading UI
│   ├── error.tsx            # Global error boundary
│   ├── api/
│   │   └── photos/
│   │       └── route.ts     # API route for client‑side fetching
│   └── photos/[id]/
│       ├── page.tsx         # Dynamic photo detail page
│       ├── loading.tsx      # Photo detail loading state
│       └── not-found.tsx    # 404 page
├── components/
│   ├── ui/
│   │   └── Button.tsx       # Reusable button component
│   ├── ImageCard.tsx        # Photo card with hover effects
│   ├── ImageGrid.tsx        # Responsive grid layout
│   ├── ImageDetail.tsx      # Photo detail view
│   ├── Navigation.tsx       # Header navigation
│   ├── ThemeToggle.tsx      # Dark mode toggle (Client)
│   ├── SearchBar.tsx        # Search input with debounce (Client)
│   ├── FilterBar.tsx        # Category filters (Client)
│   └── GalleryClient.tsx    # Client‑side orchestrator
├── lib/
│   ├── api.ts               # Unsplash API functions
│   ├── types.ts             # TypeScript interfaces
│   └── utils.ts             # Helper functions
└── providers/
    └── ThemeProvider.tsx    # Theme context provider

⚡ Server Components Strategy

Server vs. Client Components

// app/page.tsx – Server Component (default)
export default async function Home() {
  // ✅ Fetch data on the server
  const initialPhotos = await getPhotos({
    page: 1,
    perPage: 20,
    orderBy: "popular",
  });

  return <GalleryClient initialPhotos={initialPhotos} />;
}

// components/GalleryClient.tsx – Client Component
"use client"; // ← Explicit directive

export function GalleryClient({ initialPhotos }: Props) {
  const [photos, setPhotos] = useState(initialPhotos);
  // ...interactive logic
}

Why This Matters

Server Components

  • ✅ No JavaScript sent to the client
  • ✅ Direct API/database access
  • ✅ Better SEO (HTML includes content)
  • ✅ Faster initial load

Client Components

  • ✅ Interactivity (hooks, event handlers)
  • ✅ Access to browser APIs (localStorage, etc.)
  • ✅ Real‑time updates

The Pattern

Server Component (page.tsx)

Fetch data on server

Pass to Client Component

Client Component (GalleryClient.tsx)

Handle user interactions

Fetch additional data via API route

Result: fast SSR render + rich client interactivity with an optimal bundle size.

🚀 Dynamic Routes with SSG

// app/photos/[id]/page.tsx
export async function generateStaticParams() {
  const photos = await getPhotosForStaticGeneration(30);
  return photos.map((photo) => ({ id: photo.id }));
}

Build‑time output

npm run build

# Example output:
 /photos/[id]                  (Static)
 /photos/abc123
 /photos/def456
 /photos/xyz789
... (30 total pages)

Dynamic Metadata for SEO

export async function generateMetadata({ params }: Props): Promise<any> {
  const { id } = await params;
  const photo = await getPhotoById(id);

  return {
    title: `${photo.alt_description} - Photo Gallery`,
    description: photo.description || `Photo by ${photo.user.name}`,
    openGraph: {
      images: [
        {
          url: photo.urls.regular,
          width: photo.width,
          height: photo.height,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
    },
  };
}

Benefits

  • Perfect SEO with unique meta tags per photo
  • Beautiful social‑media previews
  • Instant page loads for the pre‑generated set

🖼 Image Optimization

Next.js Image Component

(Insert your next/image usage here – the original article left this section empty.)

Key Optimizations

  • Automatic format selection – WebP, AVIF, fallback to JPEG/PNG
  • Responsive imagessizes="(max-width: 768px) 100vw, 33vw"
  • Lazy loadingpriority={true} only for the first four images
  • Blur placeholder – colored SVG placeholder to avoid CLS

Configuration

// next.config.ts
export default {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.unsplash.com",
        pathname: "/**",
      },
    ],
  },
};

🔍 Search & Filtering

Debounced Search Component

// components/SearchBar.tsx
export function SearchBar({ onSearch }: Props) {
  const [query, setQuery] = useState("");

  useEffect(() => {
    const debouncedSearch = debounce((value: string) => {
      onSearch(value);
    }, 500); // Wait 500 ms after user stops typing

    debouncedSearch(query);
  }, [query, onSearch]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search photos..."
    />
  );
}

Why Debouncing?
Without it, every keystroke would trigger a network request, leading to unnecessary load on the API and a poor user experience. Debouncing batches rapid input into a single request after the user pauses.

All code snippets are functional and can be copied directly into a Next.js 16 project.

Back to Blog

Related posts

Read more »