Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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
.env.local:PEXELS_API_KEY=your_api_key_hereStep 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 };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>
);
}Authorization header containing just the API key (no "Bearer" prefix)./v1/search endpoint accepts query, orientation (landscape, portrait, square), size (large, medium, small), color, locale, page, and per_page (max 80)./v1/curated endpoint returns editorially curated photos, useful for default or featured content.src object with pre-generated sizes: original, large2x (1880px), large (940px), medium (350px), small (130px), portrait (800x1200), landscape (1200x627), and tiny (280x200).avg_color field provides the dominant color as a hex string, useful as a placeholder background while the image loads.next_page and prev_page URLs in the response body instead of total page counts.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",
});PexelsPhotoSrc interface maps directly to the API's src object. Every size key is always present in the response.next_page and prev_page fields are optional strings (URLs) that may be absent on the first or last page.Authorization header value is the raw API key, not Bearer <key>. Using the Bearer prefix will return a 401.per_page maximum is 80. Values above 80 silently default to 80.images.pexels.com as the hostname. Add this to remotePatterns in next.config.ts.alt field from the API may be empty or generic. Consider adding your own alt text for accessibility.| Approach | Pros | Cons |
|---|---|---|
| Pexels API | Good quality, curated endpoint, avg_color field | Smaller library than Unsplash |
| Unsplash API | Largest free photo library | Attribution strictly required |
| Pixabay API | No attribution needed, includes videos | Lower average quality |
| Shutterstock API | Massive library, editorial content | Paid, complex licensing |
Authorization header with the raw API key as the value.Bearer prefix; doing so returns a 401 error..env.local as PEXELS_API_KEY.page and per_page parameters.original, large2x (1880px), large (940px), medium (350px), small (130px).portrait (800x1200), landscape (1200x627), tiny (280x200).<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.
Authorization: abc123xyz.Bearer abc123xyz returns a 401 error.next_page and prev_page as optional URL strings.prev_page) or last page (no next_page).export type { PexelsPhoto, PexelsPhotoSrc, PexelsSearchResponse };Export types alongside API functions so consuming components can reference them without redeclaring.
alt field may be empty or generic.images.pexels.com to remotePatterns in next.config.ts.next/image will not load Pexels photos.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥