Building a Responsive Photo Gallery with React and Unsplash API: A Complete Guide

In this comprehensive tutorial, we'll build a fully responsive photo gallery using React and the Unsplash API. I'll explain every key concept and decision behind our implementation, with special focus on the core components: Gallery, Pagination, Search, and App.
The complete code is available on GitHub Repository
Project Setup
Creating the React App
We'll use Vite for our React project because it offers:
Faster development server start times
Optimized production builds
Native ES modules support
npm create vite@latest unsplash-gallery --template react
cd unsplash-gallery
Installing Dependencies
npm install axios react-icons
axios: For making HTTP requests to the Unsplash API (better error handling than fetch)
react-icons: Provides easy-to-use icons (better than importing font libraries)
Environment Configuration
Create a .env file in your project root:
VITE_UNSPLASH_ACCESS_KEY=your_access_key_here
Why environment variables?
Keeps API keys out of source control
Allows different configurations for development/production
Follows security best practices
Component Architecture
Our app will consist of these main components:
App: Main container and state management
Gallery: Displays images in a responsive grid
Pagination: Handles page navigation
SearchBar: User input for searching images
ImageCard: Individual image display component
src/
├── components/
│ ├── Gallery/
│ ├── Pagination/
│ ├── SearchBar/
│ └── ImageCard/
├── App.jsx
├── main.jsx
└── styles/
Building the Gallery Component
src/components/Gallery/Gallery.jsx:
import { FaSpinner } from 'react-icons/fa';
import ImageCard from '../ImageCard/ImageCard';
import './Gallery.css';
export default function Gallery({ images, isLoading, error }) {
// Loading state - shows spinner animation
if (isLoading) {
return (
<div className="loading">
<FaSpinner className="spinner" />
<p>Loading images...</p>
</div>
);
}
// Error state - shows error message
if (error) {
return <div className="error">Error: {error}</div>;
}
// Empty state - when no images are found
if (images.length === 0) {
return <div className="empty">No images found. Try another search!</div>;
}
// Main gallery grid
return (
<div className="gallery">
{images.map((image) => (
<ImageCard key={image.id} image={image} />
))}
</div>
);
}
Key Features Explained:
Conditional Rendering:
Shows different UI states (loading, error, empty, content)
Provides better user experience than empty screens
ImageCard Component:
Separates individual image display logic
Makes Gallery component more maintainable
Performance Optimization:
Uses
keyprop for efficient React renderingLoading state prevents layout shifts
CSS Grid Layout:
- Responsive design handled in CSS (shown below)
src/components/Gallery/Gallery.css:
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 768px) {
.gallery {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 480px) {
.gallery {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
}
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #3a86ff;
}
.spinner {
font-size: 2rem;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error, .empty {
text-align: center;
padding: 2rem;
color: #ff3333;
}
.empty {
color: #666;
}
Why CSS Grid?
Creates flexible layouts that adapt to screen size
auto-fillautomatically adjusts number of columnsminmax()ensures cards don't get too small or large
Implementing Pagination
src/components/Pagination/Pagination.jsx:
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import './Pagination.css';
export default function Pagination({ currentPage, totalPages, onPageChange }) {
// Don't render if only one page exists
if (totalPages <= 1) return null;
return (
<div className="pagination">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="pagination-button"
aria-label="Previous page"
>
<FaChevronLeft />
</button>
<span className="page-info">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="pagination-button"
aria-label="Next page"
>
<FaChevronRight />
</button>
</div>
);
}
Key Features Explained:
Conditional Rendering:
- Only shows pagination when needed (totalPages > 1)
Accessibility:
aria-labelhelps screen readersDisabled states for better UX
Props Design:
Receives current state from parent
Callback for page changes (keeps logic in parent)
Visual Feedback:
Hover states (in CSS)
Clear current page indication
src/components/Pagination/Pagination.css:
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 2rem 0;
padding: 0 1rem;
}
.pagination-button {
background-color: #3a86ff;
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.3s;
}
.pagination-button:hover:not(:disabled) {
background-color: #2667cc;
}
.pagination-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.page-info {
font-size: 0.9rem;
color: #333;
}
Why this pagination approach?
Simple and intuitive navigation
Works well with API pagination
Mobile-friendly design
Creating the Search Functionality
src/components/SearchBar/SearchBar.jsx:
import { useState } from 'react';
import { FaSearch } from 'react-icons/fa';
import './SearchBar.css';
export default function SearchBar({ onSearch }) {
const [searchTerm, setSearchTerm] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (searchTerm.trim()) {
onSearch(searchTerm);
}
};
return (
<form onSubmit={handleSubmit} className="search-form">
<div className="search-container">
<FaSearch className="search-icon" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search for images..."
className="search-input"
/>
<button type="submit" className="search-button">
Search
</button>
</div>
</form>
);
}
Key Features Explained:
Controlled Input:
React state manages input value
Allows validation before submission
Form Submission:
Proper form handling (not just button click)
Works with Enter key press
Validation:
Checks for empty/whitespace-only searches
Prevents unnecessary API calls
Visual Design:
Icon for better affordance
Responsive layout (changes on mobile)
src/components/SearchBar/SearchBar.css:
.search-form {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.search-container {
display: flex;
border-radius: 30px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.search-input {
flex: 1;
padding: 1rem 1rem 1rem 3rem;
border: none;
font-size: 1rem;
font-family: 'Poppins', sans-serif;
}
.search-input:focus {
outline: none;
}
.search-button {
background-color: #3a86ff;
color: white;
border: none;
padding: 0 2rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s;
}
.search-button:hover {
background-color: #2667cc;
}
@media (max-width: 600px) {
.search-container {
flex-direction: column;
border-radius: 8px;
}
.search-input {
padding: 1rem;
}
.search-button {
padding: 1rem;
width: 100%;
}
}
Why this search implementation?
Follows standard form patterns
Provides immediate visual feedback
Works across devices
App Component: Bringing It All Together
src/App.jsx:
import { useState, useEffect } from 'react';
import axios from 'axios';
import Header from './components/Header/Header';
import SearchBar from './components/SearchBar/SearchBar';
import Gallery from './components/Gallery/Gallery';
import Pagination from './components/Pagination/Pagination';
import './App.css';
export default function App() {
// State management
const [images, setImages] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('nature');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const apiKey = import.meta.env.VITE_UNSPLASH_ACCESS_KEY;
// API fetch function
const fetchImages = async () => {
try {
setIsLoading(true);
setError(null);
const response = await axios.get('https://api.unsplash.com/search/photos', {
params: {
query: searchTerm,
page,
per_page: 12, // Optimal number for grid layout
client_id: apiKey
}
});
setImages(response.data.results);
setTotalPages(response.data.total_pages);
} catch (err) {
setError(err.message || 'Failed to fetch images');
} finally {
setIsLoading(false);
}
};
// Fetch images when search term or page changes
useEffect(() => {
if (apiKey) {
fetchImages();
} else {
setError('Unsplash API key is missing');
setIsLoading(false);
}
}, [searchTerm, page, apiKey]);
// Handler for search submission
const handleSearch = (term) => {
setSearchTerm(term);
setPage(1); // Reset to first page for new searches
};
// Handler for page changes
const handlePageChange = (newPage) => {
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="app">
<Header />
<SearchBar onSearch={handleSearch} />
<Gallery images={images} isLoading={isLoading} error={error} />
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
);
}
Key Features Explained:
State Management:
Centralizes all application state
Includes loading and error states
API Integration:
Uses axios for better error handling
Implements proper loading states
Component Composition:
Clean separation of concerns
Props drilling for communication
User Experience:
Scrolls to top on page change
Resets page on new searches
Error Handling:
Checks for API key presence
Gracefully handles API errors
Final Thoughts
This implementation provides a solid foundation for a production-ready photo gallery with:
Responsive Design: Works on all screen sizes
Performance Optimizations: Lazy loading, efficient rendering
Robust Error Handling: Graceful degradation
Maintainable Architecture: Well-organized components
Good Practices: Environment variables, accessibility





