React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

pexelsapifree-imagesstock-photoscurated

Pexels API

Recipe

Use the Pexels API to fetch high-quality, free-to-use photos and videos. Pexels uses an Authorization header for authentication and provides curated collections alongside search.

Step 1: Get an API key

  1. Create an account at pexels.com.
  2. Visit pexels.com/api and request an API key.
  3. Store the key in .env.local:
PEXELS_API_KEY=your_api_key_here

Step 2: Create a typed API client

// lib/pexels.ts
interface PexelsPhotoSrc {
  original: string;
  large2x: string;
  large: string;
  medium: string;
  small: string;
  portrait: string;
  landscape: string;
  tiny: string;
}
 
interface PexelsPhoto {
  id: number;
  width: number;
  height: number;
  url: string;
  photographer: string;
  photographer_url: string;
  photographer_id: number;
  avg_color: string;
  src: PexelsPhotoSrc;
  alt: string;
}
 
interface PexelsSearchResponse {
  total_results: number;
  page: number;
  per_page: number;
  photos: PexelsPhoto[];
  next_page?: string;
  prev_page?: string;
}
 
const PEXELS_BASE = "https://api.pexels.com/v1";
 
async function pexelsFetch<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
  const url = new URL(`${PEXELS_BASE}${endpoint}`);
  if (params) {
    Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
  }
 
  const res = await fetch(url.toString(), {
    headers: {
      Authorization: process.env.PEXELS_API_KEY!,
    },
    next: { revalidate: 3600 },
  });
 
  if (!res.ok) {
    throw new Error(`Pexels API error: ${res.status} ${res.statusText}`);
  }
 
  return res.json();
}
 
export async function searchPhotos(query: string, page = 1, perPage = 15) {
  return pexelsFetch<PexelsSearchResponse>("/search", {
    query,
    page: String(page),
    per_page: String(perPage),
  });
}
 
export async function getCuratedPhotos(page = 1, perPage = 15) {
  return pexelsFetch<PexelsSearchResponse>("/curated", {
    page: String(page),
    per_page: String(perPage),
  });
}
 
export async function getPhoto(id: number) {
  return pexelsFetch<PexelsPhoto>(`/photos/${id}`);
}
 
export type { PexelsPhoto, PexelsPhotoSrc, PexelsSearchResponse };

Working Example

A hero image picker component that lets editors search and select a hero image:

// app/hero-picker/page.tsx
import { getCuratedPhotos, searchPhotos } from "@/lib/pexels";
import { HeroImagePicker } from "./hero-image-picker";
 
interface PageProps {
  searchParams: Promise<{ q?: string }>;
}
 
export default async function HeroPickerPage({ searchParams }: PageProps) {
  const params = await searchParams;
  const query = params.q;
 
  const results = query
    ? await searchPhotos(query, 1, 12)
    : await getCuratedPhotos(1, 12);
 
  return (
    <main className="mx-auto max-w-5xl px-4 py-8">
      <h1 className="mb-2 text-3xl font-bold">Hero Image Picker</h1>
      <p className="mb-6 text-gray-500">
        {query ? `Results for "${query}"` : "Curated photos"}
      </p>
      <HeroImagePicker photos={results.photos} initialQuery={query || ""} />
    </main>
  );
}
// app/hero-picker/hero-image-picker.tsx
"use client";
 
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import type { PexelsPhoto } from "@/lib/pexels";
 
interface HeroImagePickerProps {
  photos: PexelsPhoto[];
  initialQuery: string;
}
 
export function HeroImagePicker({ photos, initialQuery }: HeroImagePickerProps) {
  const [query, setQuery] = useState(initialQuery);
  const [selected, setSelected] = useState<PexelsPhoto | null>(null);
  const router = useRouter();
 
  function handleSearch(e: React.FormEvent) {
    e.preventDefault();
    if (query.trim()) {
      router.push(`/hero-picker?q=${encodeURIComponent(query.trim())}`);
    }
  }
 
  return (
    <div>
      <form onSubmit={handleSearch} className="mb-6 flex gap-2">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search for hero images..."
          className="flex-1 rounded-lg border border-gray-300 px-4 py-2"
        />
        <button
          type="submit"
          className="rounded-lg bg-emerald-600 px-6 py-2 text-white hover:bg-emerald-700"
        >
          Search
        </button>
      </form>
 
      {selected && (
        <div className="mb-8 overflow-hidden rounded-2xl border-2 border-emerald-500">
          <p className="bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700">
            Selected Hero Image
          </p>
          <div className="relative aspect-[21/9]">
            <Image
              src={selected.src.large2x}
              alt={selected.alt}
              fill
              sizes="100vw"
              priority
              className="object-cover"
            />
          </div>
          <div className="flex items-center justify-between bg-gray-50 px-4 py-3 text-sm">
            <span>
              Photo by{" "}
              <a
                href={selected.photographer_url}
                target="_blank"
                rel="noopener noreferrer"
                className="font-medium text-emerald-600 hover:underline"
              >
                {selected.photographer}
              </a>
              {" on "}
              <a
                href="https://www.pexels.com"
                target="_blank"
                rel="noopener noreferrer"
                className="font-medium text-emerald-600 hover:underline"
              >
                Pexels
              </a>
            </span>
            <span className="text-gray-500">
              {selected.width} x {selected.height}
            </span>
          </div>
        </div>
      )}
 
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
        {photos.map((photo) => (
          <button
            key={photo.id}
            onClick={() => setSelected(photo)}
            className={`group relative overflow-hidden rounded-lg transition-all ${
              selected?.id === photo.id
                ? "ring-3 ring-emerald-500 ring-offset-2"
                : "hover:ring-2 hover:ring-gray-300"
            }`}
          >
            <div className="relative aspect-[3/2]" style={{ backgroundColor: photo.avg_color }}>
              <Image
                src={photo.src.medium}
                alt={photo.alt}
                fill
                sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
                className="object-cover"
              />
            </div>
            <div className="absolute bottom-0 left-0 right-0 bg-black/50 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
              {photo.photographer}
            </div>
          </button>
        ))}
      </div>
    </div>
  );
}

Deep Dive

How It Works

  • The Pexels API authenticates via an Authorization header containing just the API key (no "Bearer" prefix).
  • The /v1/search endpoint accepts query, orientation (landscape, portrait, square), size (large, medium, small), color, locale, page, and per_page (max 80).
  • The /v1/curated endpoint returns editorially curated photos, useful for default or featured content.
  • Each photo object includes an src object with pre-generated sizes: original, large2x (1880px), large (940px), medium (350px), small (130px), portrait (800x1200), landscape (1200x627), and tiny (280x200).
  • The avg_color field provides the dominant color as a hex string, useful as a placeholder background while the image loads.
  • Pagination uses next_page and prev_page URLs in the response body instead of total page counts.

Variations

Using avg_color as a loading placeholder:

<div
  className="relative aspect-[3/2]"
  style={{ backgroundColor: photo.avg_color }}
>
  <Image src={photo.src.medium} alt={photo.alt} fill className="object-cover" />
</div>

Fetching by orientation:

const landscapePhotos = await pexelsFetch<PexelsSearchResponse>("/search", {
  query: "mountains",
  orientation: "landscape",
  per_page: "10",
});

TypeScript Notes

  • The PexelsPhotoSrc interface maps directly to the API's src object. Every size key is always present in the response.
  • Export types from your API client module so components can reference them without redeclaring.
  • The next_page and prev_page fields are optional strings (URLs) that may be absent on the first or last page.

Gotchas

  • The Authorization header value is the raw API key, not Bearer <key>. Using the Bearer prefix will return a 401.
  • Pexels requires attribution: credit the photographer and link to Pexels. While not legally mandatory for all use cases, it is strongly encouraged and part of the API terms.
  • Rate limit is 200 requests per hour and 20,000 per month. Exceeding it returns a 429 status.
  • The per_page maximum is 80. Values above 80 silently default to 80.
  • Photo URLs from Pexels use images.pexels.com as the hostname. Add this to remotePatterns in next.config.ts.
  • The alt field from the API may be empty or generic. Consider adding your own alt text for accessibility.

Alternatives

ApproachProsCons
Pexels APIGood quality, curated endpoint, avg_color fieldSmaller library than Unsplash
Unsplash APILargest free photo libraryAttribution strictly required
Pixabay APINo attribution needed, includes videosLower average quality
Shutterstock APIMassive library, editorial contentPaid, complex licensing

FAQs

How does Pexels API authentication work?
  • Use an Authorization header with the raw API key as the value.
  • Do not use a Bearer prefix; doing so returns a 401 error.
  • Store the key in .env.local as PEXELS_API_KEY.
What is the /v1/curated endpoint, and when should you use it?
  • It returns editorially curated photos selected by Pexels staff.
  • Use it for default content, featured sections, or when no search query is provided.
  • It accepts page and per_page parameters.
What photo sizes does the Pexels src object include?
  • original, large2x (1880px), large (940px), medium (350px), small (130px).
  • portrait (800x1200), landscape (1200x627), tiny (280x200).
  • Every size key is always present in the response.
What is the avg_color field, and how can you use it?
<div style={{ backgroundColor: photo.avg_color }}>
  <Image src={photo.src.medium} alt={photo.alt} fill />
</div>

It provides the dominant color as a hex string, useful as a placeholder background while the image loads.

Gotcha: What is the correct format for the Pexels Authorization header?
  • The value is the raw API key string, e.g., Authorization: abc123xyz.
  • Using Bearer abc123xyz returns a 401 error.
  • This differs from most APIs that use the Bearer prefix.
What are the Pexels API rate limits?
  • 200 requests per hour.
  • 20,000 requests per month.
  • Exceeding either limit returns a 429 status.
Is attribution required for Pexels photos?
  • Not legally mandatory for all use cases.
  • However, crediting the photographer and linking to Pexels is strongly encouraged and part of the API terms.
How does pagination work in the Pexels API response?
  • The response includes next_page and prev_page as optional URL strings.
  • These may be absent on the first page (no prev_page) or last page (no next_page).
  • This differs from APIs that return total page counts.
What TypeScript types should you export from the Pexels API client?
export type { PexelsPhoto, PexelsPhotoSrc, PexelsSearchResponse };

Export types alongside API functions so consuming components can reference them without redeclaring.

Gotcha: Can the alt field from the Pexels API be empty?
  • Yes, the alt field may be empty or generic.
  • Always consider adding your own alt text for better accessibility.
What per_page maximum does Pexels allow, and what happens if you exceed it?
  • Maximum is 80.
  • Values above 80 silently default to 80 without an error.
What hostname must you add to remotePatterns for Pexels images?
  • Add images.pexels.com to remotePatterns in next.config.ts.
  • Without this, next/image will not load Pexels photos.