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
- Create an account at pexels.com.
- Visit pexels.com/api and request an API key.
- Store the key in
.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 };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
Authorizationheader containing just the API key (no "Bearer" prefix). - The
/v1/searchendpoint acceptsquery,orientation(landscape, portrait, square),size(large, medium, small),color,locale,page, andper_page(max 80). - The
/v1/curatedendpoint returns editorially curated photos, useful for default or featured content. - Each photo object includes an
srcobject with pre-generated sizes:original,large2x(1880px),large(940px),medium(350px),small(130px),portrait(800x1200),landscape(1200x627), andtiny(280x200). - The
avg_colorfield provides the dominant color as a hex string, useful as a placeholder background while the image loads. - Pagination uses
next_pageandprev_pageURLs 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
PexelsPhotoSrcinterface maps directly to the API'ssrcobject. 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_pageandprev_pagefields are optional strings (URLs) that may be absent on the first or last page.
Gotchas
- The
Authorizationheader value is the raw API key, notBearer <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_pagemaximum is 80. Values above 80 silently default to 80. - Photo URLs from Pexels use
images.pexels.comas the hostname. Add this toremotePatternsinnext.config.ts. - The
altfield from the API may be empty or generic. Consider adding your own alt text for accessibility.
Alternatives
| 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 |
FAQs
How does Pexels API authentication work?
- Use an
Authorizationheader with the raw API key as the value. - Do not use a
Bearerprefix; doing so returns a 401 error. - Store the key in
.env.localasPEXELS_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
pageandper_pageparameters.
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 abc123xyzreturns 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_pageandprev_pageas optional URL strings. - These may be absent on the first page (no
prev_page) or last page (nonext_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
altfield 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.comtoremotePatternsinnext.config.ts. - Without this,
next/imagewill not load Pexels photos.