React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

unsplashapifree-imagesstock-photosserver-component

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

  1. Create a developer account at unsplash.com/developers.
  2. Create a new application to receive an Access Key.
  3. Store the key in .env.local:
UNSPLASH_ACCESS_KEY=your_access_key_here

Step 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/photos endpoint accepts query, page, per_page, orientation, color, and order_by parameters.
  • The /photos/random endpoint returns random photos, optionally filtered by query, collections, or topics.
  • Photos come with multiple URL sizes: raw (original), full (high-res JPEG), regular (1080px wide), small (400px wide), and thumb (200px wide).
  • The Unsplash API terms require you to trigger a download event via the download_location URL 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-js SDK 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=referral in attribution links.
  • You must call the download_location endpoint 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 raw URL returns an unprocessed image that can be very large (10MB+). Use regular or small for display and full for downloads.
  • next/image requires images.remotePatterns for images.unsplash.com in next.config.ts. Without this, images will fail to load.

Alternatives

ApproachProsCons
Unsplash APIHighest quality photos, large libraryAttribution required, rate limits
Pexels APINo attribution required (but appreciated), generous limitsSmaller library than Unsplash
Pixabay APITruly free, no attribution neededLower average quality, ads in results
Lorem PicsumSimple URL-based placeholders, no API keyOnly 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.local as UNSPLASH_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=referral in attribution links.
  • You must also call the download_location endpoint 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-js SDK 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 raw URL returns an unprocessed image that can be very large (10MB+).
  • Use regular or small for display.
  • Reserve full for 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_location field.
  • You must call it when a user actively selects or downloads a photo.
  • This is a requirement of the Unsplash API terms, not optional.