Unsplash API — Stock Photos with Attribution
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
Your app needs real photography — hero images, blog illustrations, card backgrounds —
without a designer or a stock-photo subscription. Unsplash's API is free and this recipe
gives you a complete, copy-paste UnsplashService (plain Net::HTTP + JSON, no gem
required — important, since Leo instances can't add gems) plus the attribution markup
Unsplash's terms require on every use.
When to use: you need searchable, high-quality stock photos at render/build time. When not to: the user is uploading their own images (that's Active Storage), or you need guaranteed-forever image URLs with zero external dependency (download nothing — Unsplash requires hotlinking, see Gotchas).
The 80/20 in one breath
- Register a (free) app at
unsplash.com/developersand copy its Access Key. - Add
UNSPLASH_ACCESS_KEYto the.env, then recreate the web container so it loads. - Copy the
UnsplashServiceclass below intoapp/services/unsplash_service.rb. UnsplashService.new.search("mountains", count: 3)→ array of photo hashes withurl,photographer, and ready-made attribution links.- Render the image with the
<figure>+ attribution<figcaption>pattern — attribution is mandatory, not decorative.
Layer 1 — Environment keys (.env)
# .env (project root, next to docker-compose.yml)
# REQUIRED — the "Access Key" from your app at https://unsplash.com/developers
# (Client-ID auth; you do NOT need the Secret Key for search/fetch)
UNSPLASH_ACCESS_KEY=your_access_key_here
# OPTIONAL — your app's name, used in the utm_source attribution params.
# Defaults to "leonardo_rails_app" if unset. Use your registered Unsplash app name.
UNSPLASH_APP_NAME=my_app_name
Getting the key: log in at unsplash.com/developers → Your apps → New
Application → accept the terms → copy the Access Key from the app page. New apps
start in Demo mode: 50 requests/hour, which is plenty for build-time image sourcing.
After editing .env, a plain restart is NOT enough — Docker only reads .env at
container creation. Recreate the web service:
# on the box, from ~/Leonardo
docker compose down llamapress && docker compose up -d llamapress
Verify it landed: docker compose exec llamapress bin/rails runner 'puts ENV["UNSPLASH_ACCESS_KEY"].to_s[0,4]'
should print the first 4 characters, not a blank line.
Layer 2 — The service (copy-paste, no gem)
# app/services/unsplash_service.rb
require 'net/http'
require 'json'
require 'uri'
class UnsplashService
UNSPLASH_API_URL = "https://api.unsplash.com"
def initialize(api_key: ENV["UNSPLASH_ACCESS_KEY"], app_name: ENV["UNSPLASH_APP_NAME"] || "leonardo_rails_app")
@api_key = api_key
@app_name = app_name
end
# Search for photos
# Example: UnsplashService.new.search("mountains", count: 1)
def search(query, count: 3, orientation: nil, size: 'regular')
params = {
query: query,
per_page: count
}
params[:orientation] = orientation if orientation
uri = URI("#{UNSPLASH_API_URL}/search/photos")
uri.query = URI.encode_www_form(params)
response = get(uri)
return response if response[:error]
data = response[:data]
(data["results"] || []).map { |photo| map_photo(photo, size) }
rescue => e
{ error: "Unsplash search failed: #{e.message}" }
end
# Get a single photo by ID
# Example: UnsplashService.new.get_by_id("abc123")
def get_by_id(photo_id, size: 'regular')
uri = URI("#{UNSPLASH_API_URL}/photos/#{photo_id}")
response = get(uri)
return response if response[:error]
map_photo(response[:data], size)
rescue => e
{ error: "Unsplash get_by_id failed: #{e.message}" }
end
private
def get(uri)
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Client-ID #{@api_key}"
req["Accept-Version"] = "v1"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
if res.is_a?(Net::HTTPSuccess)
{ data: JSON.parse(res.body) }
else
{ error: "Unsplash API error: #{res.code} #{res.body}" }
end
end
def map_photo(photo, size)
photographer = photo.dig("user", "name")
photographer_url = "#{photo.dig("user", "links", "html")}?utm_source=#{@app_name}&utm_medium=referral"
unsplash_url = "https://unsplash.com/?utm_source=#{@app_name}&utm_medium=referral"
{
url: photo.dig("urls", size),
alt_description: photo["alt_description"],
photographer: photographer,
photographer_url: photographer_url,
unsplash_link: "#{photo.dig("links", "html")}?utm_source=#{@app_name}&utm_medium=referral",
html_attribution: "Photo by <a href='#{photographer_url}'>#{photographer}</a> on <a href='#{unsplash_url}'>Unsplash</a>"
}
end
end
Layer 3 — Using it
# rails console / rails runner / any controller or job
photos = UnsplashService.new.search("golden retriever", count: 3)
# => [
# {
# url: "https://images.unsplash.com/photo-...&w=1080", # hotlink this
# alt_description: "golden retriever lying on grass",
# photographer: "Jane Doe",
# photographer_url: "https://unsplash.com/@janedoe?utm_source=my_app_name&utm_medium=referral",
# unsplash_link: "https://unsplash.com/photos/abc123?utm_source=my_app_name&utm_medium=referral",
# html_attribution: "Photo by <a href='...'>Jane Doe</a> on <a href='...'>Unsplash</a>"
# },
# ...
# ]
# Options:
UnsplashService.new.search("office", count: 1, orientation: "landscape") # landscape | portrait | squarish
UnsplashService.new.search("office", size: "small") # raw | full | regular (~1080w) | small (~400w) | thumb (~200w)
UnsplashService.new.get_by_id("abc123") # one photo hash (not an array)
Check for errors before iterating. On failure the service returns a Hash with an
:error key instead of an Array — calling .each on it will surprise you:
photos = UnsplashService.new.search(query)
if photos.is_a?(Hash) && photos[:error]
Rails.logger.warn(photos[:error]) # bad key, rate limit (403), network...
photos = []
end
Layer 4 — The view, with mandatory attribution
Unsplash's API terms require crediting the photographer AND Unsplash, both linked, with
utm_source params, every place a photo appears. The service pre-builds the URLs.
House pattern:
<%# app/views/pages/example.html.erb — photo is one hash from UnsplashService#search %>
<figure class="my-10">
<img src="<%= photo[:url] %>" alt="<%= photo[:alt_description] %>"
class="rounded-xl shadow-lg w-full h-96 object-cover" loading="lazy">
<figcaption class="text-sm text-gray-500 mt-3 text-center italic">
Photo by <a href="<%= photo[:photographer_url] %>" target="_blank" rel="noopener"
class="hover:text-blue-600 underline decoration-dotted transition-colors"><%= photo[:photographer] %></a>
on <a href="<%= photo[:unsplash_link] %>" target="_blank" rel="noopener"
class="hover:text-blue-600 underline decoration-dotted transition-colors">Unsplash</a>
</figcaption>
</figure>
For static marketing pages, a common pattern is to run the search once (console or
build step), then hardcode the returned url + attribution links into the .html.erb —
zero API calls at request time, rate limit never touched.
Gotchas (the hard-won stuff)
- Attribution is a hard requirement, not a courtesy. Photographer link + Unsplash
link, both with
?utm_source=<your_app_name>&utm_medium=referral. Skipping it violates the API terms and blocks any future production-rate-limit approval. - Hotlink the
images.unsplash.comURLs — do not download and re-host. The API guidelines require serving the URLs Unsplash returns (they're CDN-backed and support&w=/&q=resizing params you can append). - Error returns a
Hash, success returns anArray(forsearch). Guard withphotos.is_a?(Hash) && photos[:error]before iterating, or a bad key silently becomes aNoMethodErrorin the view. - Demo apps are capped at 50 requests/hour. A 403 with "Rate Limit Exceeded" means wait or apply for production. For static pages, fetch once and hardcode the URLs.
.envedits need a container recreate, notdocker compose restart— restart reuses the old environment.down <service> && up -d <service>(scoped to the one service, so you don't take down the rest of the stack).- Missing
UNSPLASH_ACCESS_KEYfails as a 401 at request time, not at boot. If every call errors, verify the key is actually in the container's env (Layer 1'srails runnercheck) before debugging the code. alt_descriptioncan benil. Fall back to your search query for thealtattribute so accessibility/SEO don't suffer:photo[:alt_description] || query.- Production approval requires the download trigger. If you build a user-facing
photo picker (not just build-time sourcing) and want the 5,000 req/hour production
tier, Unsplash also requires hitting each photo's
links.download_locationendpoint when a user selects a photo. Build-time page decoration doesn't need this.
Files this pattern touches
.env (add UNSPLASH_ACCESS_KEY, optional UNSPLASH_APP_NAME)
app/services/unsplash_service.rb (new — the class above, verbatim)
app/views/<wherever photos render>.html.erb
How to adapt to your schema
- Static pages (most common): run
UnsplashService.new.search(...)once in the console, paste theurl+ attribution links into the ERB. No runtime dependency. - Dynamic content (e.g. auto-illustrated records): call the service in a
controller/job, cache the returned hash (e.g. a
jsonbcolumn orRails.cache) so you don't re-hit the API — and keep the attribution fields with the URL, they travel together. - Different image sizes per context: pass
size:(thumbfor cards,regularfor heroes) or append Unsplash's dynamic-resize params to the URL (&w=600&q=75). - Safe to drop:
get_by_idif you only ever search;orientationif layout doesn't care.