Unsplash API
Recipe
Use the Unsplash API to search and display high-quality, royalty-free photos in your Next.js app. Fetch data in Server Components to keep your API key secure and avoid client-side rate limiting.
Step 1: Get an API key
- Create a developer account at unsplash.com/developers.
- Create a new application to receive an Access Key.
- Store the key in
.env.local:
UNSPLASH_ACCESS_KEY=your_access_key_hereStep 2: Create a typed API client
// lib/unsplash.ts
interface UnsplashPhoto {
id: string;
alt_description: string | null;
urls: {
raw: string;
full: string;
regular: string;
small: string;
thumb: string;
};
user: {
name: string;
links: {
html: string;
};
};
links: {
download_location: string;
};
width: number;
height: number;
}
interface UnsplashSearchResponse {
total: number;
total_pages: number;
results: UnsplashPhoto[];
}
const UNSPLASH_BASE = "https://api.unsplash.com";
async function unsplashFetch<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${UNSPLASH_BASE}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
}
const res = await fetch(url.toString(), {
headers: {
Authorization: `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}`,
},
next: { revalidate: 3600 },
});
if (!res.ok) {
throw new Error(`Unsplash API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function searchPhotos(query: string, page = 1, perPage = 12) {
return unsplashFetch<UnsplashSearchResponse>("/search/photos", {
query,
page: String(page),
per_page: String(perPage),
orientation: "landscape",
});
}
export async function getRandomPhotos(count = 6, query?: string) {
const params: Record<string, string> = { count: String(count) };
if (query) params.query = query;
return unsplashFetch<UnsplashPhoto[]>("/photos/random", params);
}
export async function trackDownload(downloadLocation: string) {
await unsplashFetch<void>(downloadLocation.replace(UNSPLASH_BASE, ""));
}Working Example
An image search component that fetches from Unsplash with proper attribution:
// app/photos/page.tsx
import { searchPhotos } from "@/lib/unsplash";
import { PhotoGrid } from "./photo-grid";
import { SearchForm } from "./search-form";
interface PageProps {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function PhotosPage({ searchParams }: PageProps) {
const params = await searchParams;
const query = params.q || "nature";
const page = Number(params.page) || 1;
const results = await searchPhotos(query, page);
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">Photo Search</h1>
<SearchForm initialQuery={query} />
<p className="mb-4 text-sm text-gray-500">
{results.total.toLocaleString()} results for "{query}"
</p>
<PhotoGrid photos={results.results} />
<div className="mt-6 flex justify-center gap-4">
{page > 1 && (
<a href={`/photos?q=${query}&page=${page - 1}`} className="rounded bg-gray-200 px-4 py-2">
Previous
</a>
)}
{page < results.total_pages && (
<a href={`/photos?q=${query}&page=${page + 1}`} className="rounded bg-gray-200 px-4 py-2">
Next
</a>
)}
</div>
</main>
);
}// app/photos/search-form.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function SearchForm({ initialQuery }: { initialQuery: string }) {
const [query, setQuery] = useState(initialQuery);
const router = useRouter();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (query.trim()) {
router.push(`/photos?q=${encodeURIComponent(query.trim())}`);
}
}
return (
<form onSubmit={handleSubmit} className="mb-6 flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search photos..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2"
/>
<button type="submit" className="rounded-lg bg-black px-6 py-2 text-white hover:bg-gray-800">
Search
</button>
</form>
);
}// app/photos/photo-grid.tsx
import Image from "next/image";
interface Photo {
id: string;
alt_description: string | null;
urls: { regular: string; small: string };
user: { name: string; links: { html: string } };
width: number;
height: number;
}
export function PhotoGrid({ photos }: { photos: Photo[] }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{photos.map((photo) => (
<figure key={photo.id} className="group relative overflow-hidden rounded-xl">
<div className="relative aspect-[3/2]">
<Image
src={photo.urls.regular}
alt={photo.alt_description || "Unsplash photo"}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<figcaption className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-4 text-white opacity-0 transition-opacity group-hover:opacity-100">
Photo by{" "}
<a
href={`${photo.user.links.html}?utm_source=your_app&utm_medium=referral`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{photo.user.name}
</a>
{" on "}
<a
href="https://unsplash.com/?utm_source=your_app&utm_medium=referral"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Unsplash
</a>
</figcaption>
</figure>
))}
</div>
);
}Deep Dive
How It Works
- The Unsplash API provides RESTful endpoints for searching, listing, and retrieving photos. Authentication is via an
Authorization: Client-ID <access_key>header. - The
/search/photosendpoint acceptsquery,page,per_page,orientation,color, andorder_byparameters. - The
/photos/randomendpoint returns random photos, optionally filtered byquery,collections, ortopics. - Photos come with multiple URL sizes:
raw(original),full(high-res JPEG),regular(1080px wide),small(400px wide), andthumb(200px wide). - The Unsplash API terms require you to trigger a download event via the
download_locationURL when a user downloads or uses a photo. - Rate limits are 50 requests per hour for demo apps and 5,000 requests per hour for production (approved) apps.
Variations
Random photo component for hero sections:
// app/components/random-hero.tsx
import Image from "next/image";
import { getRandomPhotos } from "@/lib/unsplash";
export async function RandomHero() {
const [photo] = await getRandomPhotos(1, "landscape");
return (
<div className="relative h-[60vh] w-full overflow-hidden">
<Image
src={photo.urls.full}
alt={photo.alt_description || "Hero image"}
fill
priority
sizes="100vw"
className="object-cover"
/>
<div className="absolute bottom-4 right-4 text-sm text-white/80">
Photo by{" "}
<a href={photo.user.links.html} className="underline">
{photo.user.name}
</a>
</div>
</div>
);
}TypeScript Notes
- Define response types that match the Unsplash API JSON structure. The official
unsplash-jsSDK provides types, but a lightweight typed fetch is often sufficient. - Use
next: { revalidate }in fetch options to control server-side caching in Next.js.
// Optional: use the official SDK
// npm install unsplash-js
import { createApi } from "unsplash-js";
const unsplash = createApi({
accessKey: process.env.UNSPLASH_ACCESS_KEY!,
});Gotchas
- The Unsplash API requires attribution: you must credit the photographer with a link to their Unsplash profile and link back to Unsplash. Include
utm_source=your_app&utm_medium=referralin attribution links. - You must call the
download_locationendpoint when a user actively selects or downloads a photo. This is a requirement of the API terms, not optional. - Demo apps are limited to 50 requests per hour. Apply for production status to get 5,000 requests per hour.
- Never expose your Access Key in client-side code. Always fetch from Server Components or API routes.
- The
rawURL returns an unprocessed image that can be very large (10MB+). Useregularorsmallfor display andfullfor downloads. next/imagerequiresimages.remotePatternsforimages.unsplash.cominnext.config.ts. Without this, images will fail to load.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Unsplash API | Highest quality photos, large library | Attribution required, rate limits |
| Pexels API | No attribution required (but appreciated), generous limits | Smaller library than Unsplash |
| Pixabay API | Truly free, no attribution needed | Lower average quality, ads in results |
| Lorem Picsum | Simple URL-based placeholders, no API key | Only random photos, no search |
FAQs
How do you authenticate with the Unsplash API?
- Use an
Authorization: Client-ID <access_key>header on every request. - Store the access key in
.env.localasUNSPLASH_ACCESS_KEY. - Never expose the key in client-side code.
What are the rate limits for Unsplash API demo vs. production apps?
- Demo apps: 50 requests per hour.
- Production (approved) apps: 5,000 requests per hour.
- Apply for production status through the Unsplash developer dashboard.
What photo URL sizes does the Unsplash API return?
raw-- original, unprocessed (can be 10MB+).full-- high-resolution JPEG.regular-- 1080px wide.small-- 400px wide.thumb-- 200px wide.
Gotcha: What attribution is required when using Unsplash photos?
- Credit the photographer with a link to their Unsplash profile.
- Link back to Unsplash.
- Include
utm_source=your_app&utm_medium=referralin attribution links. - You must also call the
download_locationendpoint when a user downloads or uses a photo.
Why should you fetch Unsplash data in Server Components instead of client-side?
- Keeps your API key secure (never exposed in the browser).
- Avoids client-side rate limiting issues.
- Enables
next: { revalidate }for server-side caching.
How do you add Unsplash images to next/image remote patterns?
// next.config.ts
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
],
}Without this, next/image will fail to load Unsplash photos.
What TypeScript approach is used for the Unsplash API client in this recipe?
- Define interfaces matching the API JSON structure (
UnsplashPhoto,UnsplashSearchResponse). - Use a generic
unsplashFetch<T>function for type-safe requests. - The official
unsplash-jsSDK also provides types, but a lightweight typed fetch is often sufficient.
How does the searchParams prop work in the photo search page example?
interface PageProps {
searchParams: Promise<{ q?: string; page?: string }>;
}
const params = await searchParams;
const query = params.q || "nature";In Next.js App Router, searchParams is a Promise that must be awaited.
Gotcha: What happens if you use the raw URL for display purposes?
- The
rawURL returns an unprocessed image that can be very large (10MB+). - Use
regularorsmallfor display. - Reserve
fullfor downloads only.
How does the revalidation caching work in the Unsplash fetch function?
next: { revalidate: 3600 }caches the response for 1 hour on the server.- After 3600 seconds, the next request triggers a background revalidation.
- This reduces API calls and stays within rate limits.
What is the download_location endpoint, and when must you call it?
- It is a URL returned in each photo's
links.download_locationfield. - You must call it when a user actively selects or downloads a photo.
- This is a requirement of the Unsplash API terms, not optional.