source.unsplash.com Is Gone: The Post-Mortem and Every Way to Replace It
Deprecated in 2021 with “existing uses will continue to work”, switched off in June 2024, and still being written into new code today. The measured post-mortem — and the three replacements, with the proxy that gives keyless randomness back.
A stock photo service turned off a subdomain, and two years later it is still breaking documentation sites, login screens, course exercises and freshly generated code. This is a post-mortem of a URL — what it did, what killed it, and what to put in its place — plus a measured look at the stranger part of the story: the machines that write our code have not noticed it is gone.
The HTTP responses, the two changelog entries quoted word for word, the API limits, the issue trackers of the projects that broke and the counts from GitHub, npm and Stack Overflow all come from primary sources, with the method for each one in the footnotes. If you only want the fix, jump to the migration table.
What you get today, if you still request it
One command, no key, reproducible from any machine:
curl -I https://source.unsplash.com/random
HTTP/2 503
cache-control: no-cache, no-store
content-type: text/html; charset=utf-8
server: Heroku
via: 2.0 heroku-router
# body: an iframe pointing at herokucdn.com/error-pages/application-error.html
Nothing here is a DNS failure. source.unsplash.com still resolves — it is a CNAME to
a herokudns.com host — so the request is answered, just not by an application. That
detail matters more than it sounds: a browser that gets a fast 503 with an HTML body
renders a broken image placeholder, and any code that reads response.ok or an
onerror handler you never wrote takes the failure path you never tested.
| URL pattern | What it used to return | Today |
|---|---|---|
source.unsplash.com/random | A random photo, any size | 503 |
source.unsplash.com/random/1600x900 | A random photo, cropped to size | 503 |
source.unsplash.com/1600x900/?apple,desk | A random photo matching search terms | 503 |
source.unsplash.com/featured/1600x900?nature | A random featured photo | 503 |
source.unsplash.com/collection/190727/800x600 | A random photo from a collection | 503 |
source.unsplash.com/user/scottwebb/1600x900 | A random photo from one photographer | 503 |
source.unsplash.com/daily | The photo of the day | 503 |
Checked individually with curl -o /dev/null -w "%{http_code}". The search feature was
wound down first, as announced; today the whole application is off, so the distinction no longer
exists.
Three years between “deprecated” and “off”
Both announcements are still readable, in one place, at unsplash.com/documentation/changelog. Quoted in full, because the wording is the whole story:
25 November 2021 — “Unsplash Source being deprecated”
“Unsplash Source is being deprecated. Existing uses will continue to work, however for new projects use the full Unsplash API.”
11 June 2024 — “Unsplash Source sunset”
“Unsplash Source has been officially unsupported since its deprecation in 2021. As part of the final sunsetting, we will first wind down by disabling the search feature, and in the coming weeks turn off the application entirely. Existing uses of Source — particularly production-level ones — should migrate as soon as possible to the full Unsplash API.”
Read them in sequence and the failure mode is obvious. The 2021 notice contained a promise (existing uses will continue to work) and no date. A developer who read it in 2021 had every reason to leave working code alone; a developer who joined in 2022 never read it at all. The 2024 notice gave “the coming weeks”, three years later, on a page nobody had bookmarked.
Unsplash does publish a deprecation policy, and it is a reasonable one — the documentation states
that for publicly documented fields and endpoints, changes are announced on the changelog with at
least 3 weeks of notice, and endpoints return a Warning header
during the deprecation period. The same paragraph contains the sentence that explains why none of
it protected Source: “For any non-publicly documented fields or endpoints, we may make changes
to these with no warning.” Source was never an endpoint of the documented API. It sat outside
the policy that would have covered it.
- The practical lesson is not “Unsplash was careless”. It is that a URL you can use without reading any documentation is a URL whose deprecation policy you have also not read.
- Breakage preceded the announcement. A Drupal issue filed on 28 November 2022 already reports “I get always a Heroku application error”, eighteen months before the sunset entry. Intermittent failure is how these services die: slowly, then in an announcement you never see.
The announcement is harder to find than the outage
The 2021 deprecation was published on changelog.unsplash.com, and that is the URL
every contemporaneous bug report links to — including the Drupal one above. Three
measurements:
- The HTTPS endpoint is broken.
openssl s_client -connect changelog.unsplash.com:443returnstlsv1 alert internal error— the handshake fails before any certificate is presented. Every 2021-era link, which washttps://, is therefore dead in a browser. - Over plain HTTP it redirects, but not usefully. Following
http://changelog.unsplash.com/deprecations/2021/11/25/source-deprecation.htmlends, two hops later, on a400atunsplash.com/@documentation/changelog/deprecations/2021/11/25/source-deprecation/html— the path has been swallowed by the site's username route. - The archive has a hole where the sunset is. The Wayback Machine's last successful capture of the old changelog is 24 March 2024; its first capture of the new one is 23 August 2024. The sunset was announced on 11 June 2024 — inside that five-month gap.
None of that is a conspiracy; it is an ordinary CMS migration. But the consequence is real, and it is the reason this article quotes both entries in full: the primary record of a deprecation should outlive the thing it deprecated, and here it very nearly did not.
What actually broke
Not side projects. The failures below are public issue-tracker entries; the titles, dates and states come from the GitHub and drupal.org APIs.
| Project | Issue | Opened | What it says |
|---|---|---|---|
| MUI (Material UI) | #42736 | 24 Jun 2024 | “[docs] Random Unsplash photo URL is no longer functional” — the official Sign-in side template shipped a dead image. Closed three days later. |
| Nextcloud | #115 | 17 Jan 2023 | “Migrate to Unsplash API” — the background app was built on Source URIs. Open for eighteen months, closed 16 July 2024. |
| sindresorhus/Actions | #248 | 28 May 2024 | “Get Unsplash Image: 503 Error” — an iOS/macOS Shortcuts action, broken two weeks before the sunset was announced. |
| Drupal — Gin Login | #3324054 | 28 Nov 2022 | “Unsplash has deprecated source.unsplash.com — this delays reCAPTCHA from loading, preventing users from logging in.” |
Read that last row again, because it is the one worth internalising. A decorative image next to a login form — the most obviously non-critical asset on the page — degraded into an authentication outage, because a slow third-party request sat in front of the CAPTCHA the login form needed. Nobody wrote it that way. It emerged from the order in which a browser loads things.
Your coding assistant did not get the memo
Here is the part that turns a 2024 outage into a 2026 problem. source.unsplash.com
was documented, blogged, taught and copied for roughly eight years. All of that text is in the
training data of the models that now write our starter code — and text does not expire. Three
counts:
| Measurement | Value on 30 Aug 2026 | How it was taken |
|---|---|---|
Files containing source.unsplash.com |
3,344 | GitHub code search API, q=source.unsplash.com (indexed public code only — a floor, not a total) |
| Repositories in a 100-file sample created after the sunset | 12 of 77 | Same query, 100 results, deduplicated to 77 repositories, created_at compared to 11 Jun 2024 |
| …and repositories in that sample pushed to in the last 12 months | 20 of 77 | Live repositories, not archives — including elastic/kibana, whose demo file still reads imageUrl: 'https://source.unsplash.com/64x64/?dingo' |
Monthly downloads of unsplash-source-es6 |
23 | npm registry API — a wrapper for a dead service, last published in 2022, still being installed |
| Stack Overflow posts mentioning it | 1,459 | Stack Exchange API, /search/excerpts total |
The most direct evidence is not in application code at all — it is in prompts. The top result for that search is a library of GPT system prompts containing the line “please use unsplash API( https://source.unsplash.com/1280x720/?<PUT YOUR QUERY HERE>”. That instruction is still being copied into new assistants today. The model does not verify the URL; it was told to use it, and every example it ever saw agreed.
So the generated-code failure has two independent causes, and fixing one does not fix the other: stale training data, and stale instructions written by humans on top of it. Either way, the symptom is the same family of images that never load:
source.unsplash.com/random/1200x800 # 503 since mid-2024 — never comes back
images.unsplash.com/photo-… # real CDN, but memorised IDs may not exist
via.placeholder.com/400 # grey rectangle, shipped to production
placehold.co/800x600 # grey rectangle, on purpose
picsum.photos/800/600 # a real photo, unrelated to your page
/placeholder.png # a file that was never added to the repo
A footnote on the second line of that list: while writing this, via.placeholder.com
would not complete a TLS handshake from our test network either, and answered 403
over plain HTTP. Check it from your own network before trusting it — the fallback these tools
reach for may have an outage story of its own.
Only the first one is broken. The others are worse in a subtler way: they load, the layout looks finished, and nobody notices that the page is illustrated with nothing in particular. And none of this is specific to images — it is the general shape of the problem. A model's picture of the web is a snapshot, and endpoints, CLI flags, package names and free tiers keep moving after the shutter closes.
The migration table
There are exactly three destinations, and the honest way to present them is by what you give up. Pick the column first, then read your row.
| Old Source URL | A. Fixed CDN URLno key · no randomness | B. Unsplash APIkey · server-side call | C. Your own proxykey hidden · randomness back |
|---|---|---|---|
/random |
images.unsplash.com/photo-… — one photo you chose |
GET /photos/random |
/?w=1600 |
/random/1600x900 |
…?w=1600&h=900&fit=crop |
/photos/random + Imgix params on the returned URL |
/?w=1600&h=900&fit=crop |
/1600x900/?apple,desk |
No equivalent — pick a photo by hand | /photos/random?query=apple,desk |
/?query=apple,desk&w=1600 |
/featured/1600x900?nature |
No equivalent | /photos/random?query=nature “featured” has no successor |
/?query=nature&w=1600 |
/collection/67920491/1600x900 |
No equivalent | /photos/random?collections=67920491 |
/?collections=67920491&w=1600 |
/user/scottwebb/1600x900 |
No equivalent | /photos/random?username=scottwebb |
/?username=scottwebb&w=1600 |
/daily |
Pin one photo, rotate it in your build | No equivalent — cache one random photo for 24 h yourself | Same, with the cache in the proxy |
Option A is the one most people actually want. If the image was decorative — a hero, a login side panel, a card background — you never needed a different photo on every request. Choose one, keep the CDN URL, and the page stops depending on anything random:
<img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1600&h=900&fit=crop&auto=format"
width="1600" height="900" alt="…">
# Officially supported params: w, h, crop, fit, fm, auto=format, q, dpr.
# Keep any ixid parameter the API gave you — it is what reports the view.
Option B is the official path, and it moves the call server-side, because a
Client-ID in front-end JavaScript is a published credential. Note the two rules that
trip people up: collections/topics cannot be combined with
query in the same request, and count (max 30) changes the response
shape to an array even when it is 1.
curl "https://api.unsplash.com/photos/random?query=nature&orientation=landscape" \
-H "Authorization: Client-ID YOUR_ACCESS_KEY" \
-H "Accept-Version: v1"
# → JSON. The image lives at .urls.regular / .urls.raw (add w/h/fit yourself).
# → X-Ratelimit-Limit: 1000 X-Ratelimit-Remaining: 999
Option C: rebuild Source, in about forty lines
If what you lost was genuinely the behaviour — a keyless URL that returns a different photo each
time, usable straight from an <img> tag, in a CMS field, or in a static site
where there is no server — then you have to run that endpoint yourself. It is one small worker in
front of the API, and the three things that make it survive contact with production are the cache,
the referrer check, and passing the rest of the query string through to the CDN.
// Cloudflare Workers. Elsewhere (Deno Deploy, Val Town…) the shape is the same,
// but open a named cache with caches.open() instead of caches.default.
// UNSPLASH_KEY stays server-side. Callers never see it.
const ALLOWED = ["example.com", "www.example.com"]; // your domains only
const API_PARAMS = ["query", "collections", "topics", "username", "orientation"];
const TTL = 60; // seconds — protects the hourly quota
const host = (value) => { try { return new URL(value).hostname; } catch { return null; } };
export default {
async fetch(req, env, ctx) {
// 0. GET only: the Cache API refuses to store anything else, and an image
// endpoint has no other verb to answer.
if (req.method !== "GET")
return new Response("Method not allowed", { status: 405 });
const url = new URL(req.url);
// 1. Only your own pages may embed it — a public random-photo endpoint
// on the open internet is someone else's rate limit to burn.
const ref = req.headers.get("referer"); // absent on plenty of legit clients
if (ref && !ALLOWED.includes(host(ref)))
return new Response("Forbidden", { status: 403 });
// 2. Cache per parameter combination, so a page with 12 images
// costs one API call per minute instead of twelve per render.
const cache = caches.default;
const hit = await cache.match(req);
if (hit) return hit;
// 3. Ask the official API for a random photo.
const api = new URL("https://api.unsplash.com/photos/random");
for (const p of API_PARAMS)
if (url.searchParams.has(p)) api.searchParams.set(p, url.searchParams.get(p));
const r = await fetch(api, { headers: {
Authorization: "Client-ID " + env.UNSPLASH_KEY,
"Accept-Version": "v1",
}});
// 403 here usually means the hourly quota, not a bad key — raise TTL, not panic.
if (!r.ok) return new Response("Upstream " + r.status, { status: 502 });
const photo = await r.json();
// 4. Rebuild the image URL: keep ixid, append the caller's sizing params.
const img = new URL(photo.urls.raw); // .raw already carries ixid
for (const [k, v] of url.searchParams)
if (!API_PARAMS.includes(k)) img.searchParams.set(k, v); // w, h, fit, q…
const res = new Response(null, { status: 302, headers: {
Location: img.toString(),
"Cache-Control": "public, max-age=" + TTL,
// Credit travels with the redirect; header values must be ASCII, hence the encoding.
"X-Photo-Credit": encodeURIComponent(photo.user.name + " on Unsplash"),
"X-Photo-Link": photo.links.html,
}});
ctx.waitUntil(cache.put(req, res.clone()));
return res;
},
};
Migrating a URL is then a search-and-replace, which is exactly what makes this option worth the twenty minutes:
- https://source.unsplash.com/collection/67920491/1600x900
+ https://img.example.com/?collections=67920491&w=1600&h=900&fit=crop
Two design notes, both of which cost someone a bad afternoon before they were written down. The
redirect (302) rather than proxying the bytes keeps you off the hook for bandwidth
and keeps the view counted on Unsplash's CDN, which is what the guidelines ask for. And
the Referer check is deliberately permissive when the header is absent — plenty of
legitimate clients strip it — while still stopping the obvious case where your endpoint becomes
somebody else's free image API.
The rules you inherit the moment you use the API
Source had no rules because it had no account. The API has five that change how you architect the thing, all from the current documentation:
- Rate limits are per hour, and small at first. 50 requests/hour
in demo mode; 1,000/hour after your application is approved for production. Only
calls to
api.unsplash.comcount — image requests toimages.unsplash.comdo not. ReadX-Ratelimit-Remainingon every response. - Hotlinking is mandatory, not merely allowed. Unsplash requires that the image URLs the API returns be embedded directly, so photo views can be attributed to the photographer. Mirroring the file onto your own CDN is the one optimisation you are not free to make.
- Keep the
ixidparameter. Resizing and cropping the returned URL is expected; stripping the parameter that identifies your application is not. - Attribution and download tracking are part of the deal — the photographer and Unsplash get credited, and a “download” is reported through the photo's download endpoint when a user takes the file, which is an event you have to fire yourself.
- Distributed products need dynamic client registration. If you ship a plugin, a theme or a self-hosted CMS, one shared key is both a policy violation and a single point of failure; the API has a registration flow for exactly that case.
This is the moment to be honest about scope: if you are wiring up an API and a key anyway, the choice of which image API is suddenly open, and it is worth spending five minutes on before you write the client. We compared the free ones — quotas, rules, search behaviour and response shapes — in the free stock photo API comparison.
If you only wanted a placeholder, say so
A large share of Source usage was never about Unsplash. It was “put something image-shaped here while I build the layout”. For that, keyless services still exist and are the correct answer:
| Service | Key? | What you get | Where it stops |
|---|---|---|---|
| Lorem Picsum | No | Real photographs: picsum.photos/800/600, a stable one with /id/237/… or /seed/xxx/…, plus ?grayscale and ?blur=1..10. Its /v2/list endpoint credits each photo's Unsplash page and author. |
No subject targeting at all. The photo will not relate to your page. |
| placehold.co | No | Labelled rectangles at any size — honest wireframe filler. | It is a grey box, and it looks like one in a screenshot shared with a client. |
| Openverse | No | An openly licensed catalogue with a public API, run by WordPress.org. | Keyword matching, and licences vary per item — you must read them. |
The distinction that matters: a placeholder is temporary by definition. If the image survives to production, it is not a placeholder any more — it is an illustration that nobody chose, and the reader can tell.
One key instead of three
Here is the part of the migration nobody plans for: people leaving Source rarely land on
one API. A page needs a hero, two section images and something for a card grid, and the
honest answer is usually Unsplash plus Pexels plus Pixabay — three
registrations, three auth schemes, three JSON shapes, three pagination models and three sets of
attribution rules, all to fill the same <img> tags. That integration work is
the real bill for a keyless URL going away, and it lands weeks after the outage that caused it.
Collapsing that into one integration is what we built Pexafy for: a single key over 9 free-licence libraries in one schema, with sentence-level semantic search — so a full description like “a cracked phone screen on a wooden desk, shot from above” returns ranked results instead of nothing. Two limits, stated plainly, because this whole article is about not being surprised twice: it needs a key, so it does not restore what Source was — it belongs in the same category as the official Unsplash API above; and it carries free-licence photography, not editorial or brand imagery.
The piece that is genuinely new is aimed at the assistants above: an MCP
server at mcp.pexafy.com/mcp means a model that would otherwise recite an image URL
from memory can search a real catalogue and return a photo that exists, with its credit line
attached. That is a better answer to machine-written dead URLs than any lint rule, and the
reasoning is written up in
image search
infrastructure for AI agents.
Audit your estate in ten minutes
Whatever you migrate to, do this part first — you cannot fix URLs you have not found. Source is just today's example; the same three steps apply to every external asset you embed.
# 1. Everything in the repo, including docs, tests, fixtures and READMEs.
grep -rn --binary-files=without-match \
-e "source.unsplash.com" -e "via.placeholder.com" -e "/placeholder.png" .
# 2. Everything the database holds — CMS bodies are where these hide longest.
psql -c "SELECT id FROM posts WHERE body LIKE '%source.unsplash.com%'"
# 3. Everything the built site actually requests: crawl it and list the failures.
# Match the attribute, not a file extension — image URLs rarely end in .jpg.
grep -rhoE 'src="[^"]+"' dist/ \
| cut -d'"' -f2 | grep -E '^https?://' | sort -u \
| xargs -P8 -I{} curl -s -o /dev/null -w "%{http_code} {}\n" {} \
| grep -v "^200"
# 503 https://source.unsplash.com/random/1200x800 ← what you are looking for
Then decide, once, how much external images are allowed to cost you. Four rules that survive the next shutdown, whoever causes it:
- Fetch at build time, not at request time. An image resolved during the build fails in CI, in front of a developer, instead of at 3 a.m. in front of a user.
- Never let a decorative asset block a critical path. Preload nothing external
above a login form; give every third-party
<img>anonerrorfallback and explicitwidth/heightso a failure costs a blank box, not a layout shift or a stalled script. - Add the check to CI. Step 3 above, run on your built output, turns “someone eventually noticed” into a red build. It is the only step that prevents recurrence.
- Budget your external dependencies like any other. Write down which hosts your pages are allowed to depend on and what happens when each one is down. A URL you did not have to register for is still a dependency — Source proved that it is simply one nobody owns.
References & footnotes
1 Every status code, count and quoted line in this article was
taken from its primary source on 30 August 2026. HTTP status codes
were taken with curl against each URL pattern; all returned 503 with
server: Heroku and a body embedding
herokucdn.com/error-pages/application-error.html. DNS resolution was confirmed the
same day (a CNAME to a herokudns.com host).
2 Both changelog entries are quoted verbatim from
unsplash.com/documentation/changelog. The deprecation-policy wording (3 weeks of notice, Warning header, and the exemption for
non-publicly documented endpoints) comes from unsplash.com/documentation, same day.
3 TLS failure reproduced with
openssl s_client -connect changelog.unsplash.com:443 (tlsv1 alert internal
error). Redirect chain followed with curl -L. Archive gap taken from the
Wayback CDX API: last 200 capture of changelog.unsplash.com on
20240324, first of unsplash.com/documentation/changelog on 20240823.
4 Issue titles, creation and closing dates read from the GitHub REST
API (mui/material-ui#42736, nextcloud/unsplash#115,
sindresorhus/Actions#248) and from the drupal.org JSON API for
gin_login issue 3324054, whose body reports “I get always a Heroku application
error” in November 2022.
5 Counts: GitHub code search API
(3,344 files; a 100-result sample deduplicated to 77 repositories, of which 12 were
created after 11 June 2024 and 20 had been pushed to within the previous 12 months);
npm registry downloads API (unsplash-source-es6, 23 downloads over the preceding
30 days); Stack Exchange /search/excerpts (1,459 posts). Code search
covers indexed public repositories only, so each figure is a floor.
Primary sources: Unsplash API changelog · Unsplash API documentation · Unsplash attribution guideline · Unsplash status · MUI #42736 · Nextcloud #115 · sindresorhus/Actions #248 · Drupal Gin Login #3324054 · Lorem Picsum · Openverse · Pexafy API & MCP docs.
Frequently asked questions
Is source.unsplash.com down, or has it been shut down permanently?
/random, /1600x900/?query, /collection/…, /daily) returns HTTP 503 with Heroku’s generic Application Error page. The hostname still resolves, so the failure shows up as a broken image rather than a network error.What is the direct replacement for source.unsplash.com/random?
images.unsplash.com/photo-…?w=1600&h=900&fit=crop — needs no key but always returns the same photo, which is what most decorative uses actually needed. The official API, GET https://api.unsplash.com/photos/random with an Authorization: Client-ID header, restores randomness but must be called server-side. A small proxy you own in front of that endpoint is the only option that gives back a keyless URL you can drop straight into an <img> tag.Why do AI coding tools still generate source.unsplash.com URLs in 2026?
Can I still get a random Unsplash photo without an API key?
/photos/random, which requires a Client-ID. Your two keyless routes are a proxy you host yourself, where the key stays server-side and the public URL looks like the old one, or a third-party placeholder service such as Lorem Picsum (picsum.photos/800/600), which serves real photographs with no key but no subject targeting at all.Does the Unsplash API allow downloading and self-hosting the images?
ixid parameter when you resize or crop the URL, credit the photographer and Unsplash, and fire the photo’s download endpoint when a user takes the file. Mirroring the files onto your own CDN is the one optimisation you are not free to make.Why did the 2021 deprecation notice not protect existing users?
Warning header — applies to publicly documented fields and endpoints, and the same paragraph states that anything undocumented may change with no warning. Source was never a documented endpoint of the API, so it sat outside the policy that would have protected it.How do I find every dead source.unsplash.com URL in my project?
grep -rn "source.unsplash.com" . — since these URLs survive longest in sample code. Query the database, because CMS article bodies are where they hide (WHERE body LIKE '%source.unsplash.com%'). Then crawl your built output: extract every image URL and request each one, listing anything that is not 200. Add that last pass to CI and a dead third-party asset fails a build instead of a page.