When a new post lands in our drafts, it already has a title, an excerpt and a body. What it doesn't have is a photo -- and a blog post without one looks bare on the homepage and flat when it's shared. We wanted every draft to pick up a relevant featured image automatically, with nobody having to go hunting on a stock photo site first. Here's how that actually works, including the part that has nothing to do with photos at all.
From a title to a search term
The obvious move is to search Unsplash for the post's title. We tried talking ourselves out of that, because titles are written to be catchy, not descriptive -- "Teaching an AI to pick photos" is a fine title and a terrible photo search. You can't photograph "teaching."
So before anything touches Unsplash, we hand the title and excerpt to Groq and ask for something more useful: a short, concrete, photographable scene. The prompt is specific about it -- it asks for a 5-8 word query describing "a real, photographable scene or object," and explicitly warns against generic words like "technology" or "learning" on their own. A post about spaced repetition doesn't need a photo of "studying" in the abstract; it needs something a camera could actually point at.
If Groq isn't configured, or the call fails for any reason, this step just returns nothing and the whole pipeline politely stops -- more on that below.
Asking Unsplash nicely
With a real search query in hand, we call Unsplash's search endpoint, ask for one photo, and request a landscape orientation so it fits a featured-image slot without weird cropping.
There's a second, easy-to-miss step here: before we're allowed to actually use a photo, Unsplash's API guidelines require a separate "download" ping to a download_location URL that comes back with the search result. It's not the download itself -- it's a tracking hit that tells Unsplash the photo was put to real use, which is how photographers get credited for views. Skip it and you're using their catalog without playing by their rules. So the code fires that ping and only then reads the actual image URL out of the response.
Unsplash's free tier caps out at 50 requests an hour, which is tight enough that it shaped how we handle failure: a rate-limit response logs the actual error body, not just a status code, so a bulk import run that comes back empty-handed tells you why instead of leaving you to guess whether it was a bad query or a locked-out API key.
Never trust a URL, even one you asked for yourself
Here's the part that surprised us most while building this. The image URL that comes back from Unsplash is, by definition, a URL we didn't write -- it's just that this time, instead of an admin pasting one into a form, it's an API response. Treating "we asked for it" as the same thing as "safe to fetch" is exactly the mistake that leads to SSRF (server-side request forgery): a server that will fetch whatever URL it's handed can be tricked into fetching things it was never supposed to reach, like a cloud provider's internal metadata endpoint or a service sitting on the private side of a firewall.
So the Unsplash image URL goes through the exact same safety-checked fetcher we already built for admin-submitted featured images, rather than a shortcut "it's probably fine" path. That fetcher only allows https://, resolves the hostname's actual IP address and refuses to proceed if it lands in a private or reserved range, re-checks every redirect hop the same way instead of trusting the first check and then blindly following wherever a 302 points, caps how many bytes it will download, and -- instead of trusting whatever Content-Type header the remote server feels like sending -- sniffs the real file type from the downloaded bytes themselves. Only once all of that passes does the image get copied into our own storage, which is the version that actually shows up on the post.
It's a lot of ceremony for a photo of a desk. But the code doesn't get to know that this particular URL came from a trusted-sounding API instead of a stranger's form submission, and it shouldn't have to -- one hardened path for "fetch an external image" is simpler to trust than two.
Nothing here is allowed to block a post
Every step above -- the Groq call, the Unsplash search, the download ping, the safe fetch -- can fail on its own for reasons that have nothing to do with the post itself: a missing API key, an empty search result, a rate limit, a flaky network. None of them throw. Each one just returns nothing, and the step after it treats "nothing" as "skip the image, not the post."
That matters because drafting a post and fetching it a photo are two different jobs with two different failure tolerances. A draft with no image is still a perfectly good draft, ready for a human to review, edit, or add a photo to by hand later. A draft that never got created because an image search timed out would be a much worse trade.
Try it
Head over to the blog and look for a post with a photo credit underneath its featured image -- that's this pipeline, end to end, from title to a photographer who never knew we existed until the moment we asked.
