Building a Modern Image Gallery with Next.js 16, TypeScript & Unsplash API
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
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 }));
}
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 images –
sizes="(max-width: 768px) 100vw, 33vw" - Lazy loading –
priority={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.