Unsplash API — Stock Photos with Attribution | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Unsplash API — Stock Photos with Attribution

Drop-in UnsplashService (plain Net::HTTP, no gem) to search and fetch stock photos, the .env keys it needs, and the mandatory photographer/Unsplash attribution pattern for views.

Proven on llamapress.ai controller · view

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

  1. Register a (free) app at unsplash.com/developers and copy its Access Key.
  2. Add UNSPLASH_ACCESS_KEY to the .env, then recreate the web container so it loads.
  3. Copy the UnsplashService class below into app/services/unsplash_service.rb.
  4. UnsplashService.new.search("mountains", count: 3) → array of photo hashes with url, photographer, and ready-made attribution links.
  5. 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/developersYour appsNew 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.com URLs — 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 an Array (for search). Guard with photos.is_a?(Hash) && photos[:error] before iterating, or a bad key silently becomes a NoMethodError in 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.
  • .env edits need a container recreate, not docker 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_KEY fails 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's rails runner check) before debugging the code.
  • alt_description can be nil. Fall back to your search query for the alt attribute 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_location endpoint 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

  1. Static pages (most common): run UnsplashService.new.search(...) once in the console, paste the url + attribution links into the ERB. No runtime dependency.
  2. Dynamic content (e.g. auto-illustrated records): call the service in a controller/job, cache the returned hash (e.g. a jsonb column or Rails.cache) so you don't re-hit the API — and keep the attribution fields with the URL, they travel together.
  3. Different image sizes per context: pass size: (thumb for cards, regular for heroes) or append Unsplash's dynamic-resize params to the URL (&w=600&q=75).
  4. Safe to drop: get_by_id if you only ever search; orientation if layout doesn't care.

Related