Adding Pexels image search to my blog manager
Medium wants a header image. My blog manager needed a way to pick one without leaving the app.
I’ve been building a Rails app that helps me syndicate posts from my Astro blog to Medium and LinkedIn. The cross-posting flow was mostly done; I could scan a GitHub repo for markdown files, render them as HTML, and push them to Medium as drafts. But there was one missing piece: Medium highlights a header image at the top of every post, and my app had no way to select one.
The obvious choice was Pexels. They have a clean API, a generous free tier, and an attribution requirement that’s easy to satisfy with a <figcaption>.
What I built
The Pexels section lives on the post detail page. There’s a search form pre-filled with the post title, a grid of results that loads without a full page reload, and a “Select” button per image. Once you pick one, the post card updates in place and the Medium import button activates.
The Turbo Frame approach made this straightforward. The search form targets a turbo_frame_tag "pexels_results", so the response replaces only that region of the page:
<%= form_with url: search_pexels_post_path(post), method: :get,
data: { turbo_frame: "pexels_results" } do |f| %>
<%= f.text_field :query, value: post.title.presence || post.slug %>
<%= f.submit "Search" %>
<% end %>
<%= turbo_frame_tag "pexels_results" %>
On the controller side, search_pexels renders the partial directly into the frame. Fast, no page transition, no scroll reset.
The Pexels auth quirk
Every other API I’ve integrated uses Authorization: Bearer {token}. Pexels doesn’t. Their auth header is just the raw API key:
req["Authorization"] = @api_key # correct
req["Authorization"] = "Bearer #{@api_key}" # wrong - 401
It’s documented, but easy to miss when copying from another client.
Where to store the API key
Per-blog vs. global: I have one Pexels account, so per-blog would mean entering the same key multiple times. I added a singleton AppSetting model instead:
class AppSetting < ApplicationRecord
encrypts :pexels_api_key
def self.current
first_or_create!
end
end
Active Record Encryption (already used for GitHub and Medium tokens) handles at-rest encryption. The settings page at /settings gives me a UI to paste the key without touching credentials.yml.enc.
Gating Medium import
The Medium import button now requires a Pexels image to be selected. The check lives in both the view (disabled tooltip) and the controller (redirect with alert); view-only gates get bypassed with a direct POST.
When DraftCreator runs
DraftCreator prepends the selected image as a <figure> before the post HTML body. The <figcaption> includes photographer attribution, satisfying Pexels’ requirements. Medium renders it at the top of the draft.
One test gotcha
minitest/mock was removed in Minitest 6.0.6; Minitest::Mock no longer exists. I dropped the stub-based controller test and relied on unit tests for Pexels::Client directly.
What’s next
The search_pexels action still reads from Rails.application.credentials; wiring it to AppSetting.current.pexels_api_key is the obvious one-liner follow-on. After that: LinkedIn cross-posting, which has the token but not the job.
Related reading
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.
The editor commit button is a deploy button
Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.