DEV Community

Cover image for Building a Modern Image Gallery with Next.js 16, TypeScript & Unsplash API
A0mineTV
A0mineTV

Posted on

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

In this article, I'll walk you through the complete architecture of a production-ready image gallery built with Next.js 16, TypeScript, and the Unsplash API. We'll explore modern React patterns, performance optimizations, and best practices.

πŸ”— Project Repository

Live Demo & Source Code: github.com/VincentCapek/next-image-gallery


πŸ“‹ Table of Contents

  1. Tech Stack & Features
  2. Project Architecture
  3. Server Components Strategy
  4. Dynamic Routes with SSG
  5. Image Optimization
  6. Search & Filtering
  7. Dark Mode Implementation
  8. Performance Optimizations
  9. Key Learnings

πŸ›  Tech Stack & Features

Core Technologies

  • Next.js 16 - Latest 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 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 design (mobile-first approach)
βœ… SEO optimized with 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
Enter fullscreen mode Exit fullscreen mode

⚑ Server Components Strategy

One of the most powerful features of Next.js 16 is the default use of Server Components. Here's how we leverage them:

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
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters

Server Components:

  • βœ… Zero JavaScript sent to client
  • βœ… Direct database/API access
  • βœ… Better SEO (HTML includes content)
  • βœ… Faster initial page load

Client Components:

  • βœ… Interactivity (hooks, event handlers)
  • βœ… 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
Enter fullscreen mode Exit fullscreen mode

This hybrid approach gives us:

  1. Fast initial render (SSR)
  2. Rich interactivity (Client)
  3. Optimal bundle size

πŸš€ Dynamic Routes with SSG

Static Site Generation for Popular Photos

// app/photos/[id]/page.tsx
export async function generateStaticParams() {
  const photos = await getPhotosForStaticGeneration(30);

  return photos.map((photo) => ({
    id: photo.id,
  }));
}
Enter fullscreen mode Exit fullscreen mode

What happens at build time:

npm run build

# Output:
β—‹ /photos/[id]                  (Static)
β”œ /photos/abc123
β”œ /photos/def456
β”œ /photos/xyz789
... (30 total pages)
Enter fullscreen mode Exit fullscreen mode

Dynamic Metadata for SEO

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  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",
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • πŸ” Perfect for SEO (unique meta tags per photo)
  • πŸ“± Beautiful social media previews
  • ⚑ Instant page loads for popular photos

πŸ–Ό Image Optimization

Next.js Image Component

<Image
  src={photo.urls.regular}
  alt={photo.alt_description}
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  priority={index < 4}
  placeholder="blur"
  blurDataURL={`data:image/svg+xml,...`}
/>
Enter fullscreen mode Exit fullscreen mode

Key Optimizations

  1. Automatic Format Selection

    • WebP for modern browsers
    • AVIF for cutting-edge browsers
    • Fallback to JPEG/PNG
  2. Responsive Images

   sizes="(max-width: 768px) 100vw, 33vw"
Enter fullscreen mode Exit fullscreen mode
  • Mobile: Full width
  • Desktop: 1/3 viewport width
  • Saves bandwidth!
  1. Lazy Loading

    • Only priority={true} for first 4 images
    • Others load as user scrolls
  2. Blur Placeholder

   blurDataURL={`data:image/svg+xml,%3Csvg...fill='${photo.color}'...`}
Enter fullscreen mode Exit fullscreen mode
  • Instant colored placeholder
  • Prevents layout shift (CLS)

Configuration

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

πŸ” Search & Filtering

Debounced Search

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

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

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

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

Why Debouncing?

  • ❌ Without: Typing "nature" = 6 API calls
  • βœ… With: Typing "nature" = 1 API call (after 500ms)

Category Filtering

const CATEGORIES = [
  { value: "nature", label: "Nature", emoji: "🌿" },
  { value: "architecture", label: "Architecture", emoji: "πŸ›οΈ" },
  // ... more categories
];

<FilterBar
  selectedCategory={selectedCategory}
  onCategoryChange={(cat) => fetchPhotos(searchQuery, cat)}
/>
Enter fullscreen mode Exit fullscreen mode

API Route Pattern

// app/api/photos/route.ts
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const query = searchParams.get("query");
  const category = searchParams.get("category");

  const photos = await getPhotos({ query, category });
  return NextResponse.json(photos);
}
Enter fullscreen mode Exit fullscreen mode

Client fetches via:

/api/photos?query=sunset&category=nature
Enter fullscreen mode Exit fullscreen mode

πŸŒ“ Dark Mode Implementation

Theme Provider with Context API

// providers/ThemeProvider.tsx
"use client";

export function ThemeProvider({ children }: Props) {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    const savedTheme = localStorage.getItem("theme");
    const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;

    setTheme(savedTheme || (prefersDark ? "dark" : "light"));
  }, []);

  useEffect(() => {
    if (!mounted) return;
    document.documentElement.classList.toggle("dark", theme === "dark");
    localStorage.setItem("theme", theme);
  }, [theme, mounted]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, mounted }}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

CSS Variables for Theming

/* app/globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 240 10% 3.9%;
  --primary: 240 5.9% 10%;
  /* ... more variables */
}

.dark {
  --background: 240 10% 3.9%;
  --foreground: 0 0% 98%;
  --primary: 0 0% 98%;
  /* ... dark mode overrides */
}
Enter fullscreen mode Exit fullscreen mode

Theme Toggle Component

export function ThemeToggle() {
  const { theme, toggleTheme, mounted } = useTheme();

  if (!mounted) {
    return <div className="h-9 w-9" />; // Prevent layout shift
  }

  return (
    <button onClick={toggleTheme}>
      <Sun className="dark:scale-0" />
      <Moon className="scale-0 dark:scale-100" />
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Features:

  • βœ… Persists preference in localStorage
  • βœ… Respects system preference
  • βœ… Smooth transitions
  • βœ… No FOUC (Flash of Unstyled Content)

⚑ Performance Optimizations

1. Caching Strategy

// lib/api.ts
export async function getPhotos(params: FetchPhotosParams) {
  const response = await fetch(url, {
    headers: getHeaders(),
    next: {
      revalidate: 3600, // Cache for 1 hour
    },
  });

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

2. Loading States

// app/loading.tsx
export default function Loading() {
  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
      {Array.from({ length: 20 }).map((_, i) => (
        <div key={i} className="aspect-[4/3] animate-pulse bg-muted" />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Error Boundaries

// app/error.tsx
"use client";

export default function Error({ error, reset }: Props) {
  return (
    <div>
      <h1>Something went wrong!</h1>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Masonry-like Grid

<ImageCard
  className={cn(
    "transition-transform hover:scale-[1.02]",
    isLandscape && "md:col-span-2",  // Wide images
    isPortrait && "md:row-span-2"    // Tall images
  )}
/>
Enter fullscreen mode Exit fullscreen mode

Performance Metrics

After optimizations:

  • First Contentful Paint (FCP): <1s
  • Largest Contentful Paint (LCP): <1.5s
  • Cumulative Layout Shift (CLS): <0.1
  • Time to Interactive (TTI): <2s

πŸŽ“ Key Learnings

1. Server Components are Game-Changing

Before (traditional Next.js):

useEffect(() => {
  fetch('/api/photos').then(/* ... */);
}, []);
// ❌ Client-side fetch, loading spinner, layout shift
Enter fullscreen mode Exit fullscreen mode

After (App Router):

const photos = await getPhotos();
// βœ… Server-side fetch, instant content, better SEO
Enter fullscreen mode Exit fullscreen mode

2. Use Client Components Strategically

Only mark components as "use client" if they need:

  • State (useState, useReducer)
  • Effects (useEffect)
  • Event handlers (onClick, onChange)
  • Browser APIs (localStorage, window)

3. TypeScript Everywhere

// lib/types.ts
export interface Photo {
  id: string;
  urls: UnsplashUrls;
  alt_description: string;
  user: UnsplashUser;
  // ... full typing
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • πŸ›‘οΈ Catch errors at compile time
  • πŸ“– Better IntelliSense
  • πŸ”§ Easier refactoring

4. Accessibility Matters

<button
  onClick={handleClick}
  aria-label="Toggle dark mode"
  className="..."
>
  <Sun className="..." />
  <span className="sr-only">Activate dark mode</span>
</button>
Enter fullscreen mode Exit fullscreen mode

5. Performance Budget

  • Lighthouse score: >90 on all metrics
  • Bundle size: <200KB (JS) for initial route
  • Images: Lazy-loaded, optimized formats
  • API calls: Debounced, cached

πŸš€ Future Enhancements

Potential features to add:

  1. Infinite Scroll
   useInfiniteScroll(() => fetchPhotos(page + 1));
Enter fullscreen mode Exit fullscreen mode
  1. URL State Sync
   router.push(`/?query=${query}&category=${category}`);
Enter fullscreen mode Exit fullscreen mode
  1. Image Download
   trackDownload(photo.links.download_location);
Enter fullscreen mode Exit fullscreen mode
  1. Collections

    • User-curated photo collections
    • Save favorites to localStorage
  2. Advanced Filters

    • Color filter
    • Orientation (landscape/portrait)
    • Date range

πŸ“¦ Getting Started

# Clone the repository
git clone https://github.com/VincentCapek/next-image-gallery.git

# Install dependencies
npm install

# Create .env.local
NEXT_PUBLIC_UNSPLASH_ACCESS_KEY=your_key_here

# Run development server
npm run dev

# Build for production
npm run build
npm start
Enter fullscreen mode Exit fullscreen mode

πŸ”— Resources


πŸ’¬ Conclusion

Building this image gallery taught me the power of Next.js 16's App Router and the importance of choosing the right pattern for each component. The combination of Server Components for data fetching and Client Components for interactivity creates a fast, SEO-friendly, and delightful user experience.

Top comments (0)