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
- Tech Stack & Features
- Project Architecture
- Server Components Strategy
- Dynamic Routes with SSG
- Image Optimization
- Search & Filtering
- Dark Mode Implementation
- Performance Optimizations
- 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
β‘ 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
}
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
This hybrid approach gives us:
- Fast initial render (SSR)
- Rich interactivity (Client)
- 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,
}));
}
What happens at build time:
npm run build
# Output:
β /photos/[id] (Static)
β /photos/abc123
β /photos/def456
β /photos/xyz789
... (30 total pages)
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",
},
};
}
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,...`}
/>
Key Optimizations
-
Automatic Format Selection
- WebP for modern browsers
- AVIF for cutting-edge browsers
- Fallback to JPEG/PNG
Responsive Images
sizes="(max-width: 768px) 100vw, 33vw"
- Mobile: Full width
- Desktop: 1/3 viewport width
- Saves bandwidth!
-
Lazy Loading
- Only
priority={true}for first 4 images - Others load as user scrolls
- Only
Blur Placeholder
blurDataURL={`data:image/svg+xml,%3Csvg...fill='${photo.color}'...`}
- Instant colored placeholder
- Prevents layout shift (CLS)
Configuration
// next.config.ts
export default {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
],
},
};
π 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..."
/>
);
}
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)}
/>
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);
}
Client fetches via:
/api/photos?query=sunset&category=nature
π 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>
);
}
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 */
}
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>
);
}
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();
}
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>
);
}
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>
);
}
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
)}
/>
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
After (App Router):
const photos = await getPhotos();
// β
Server-side fetch, instant content, better SEO
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
}
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>
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:
- Infinite Scroll
useInfiniteScroll(() => fetchPhotos(page + 1));
- URL State Sync
router.push(`/?query=${query}&category=${category}`);
- Image Download
trackDownload(photo.links.download_location);
-
Collections
- User-curated photo collections
- Save favorites to localStorage
-
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
π Resources
- Repository: github.com/VincentCapek/next-image-gallery
- Next.js Docs: nextjs.org/docs
- Unsplash API: unsplash.com/developers
- Tailwind CSS: tailwindcss.com
π¬ 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)