The xkcd JSON API is one of the nicest free APIs on the internet: keyless, no rate-limit hoops, HTTPS, fast (~0.5s in our September 2026 runs), and so stable that /info.0.json URLs from 2010 still work unchanged. Which is why it's the opening act of a thousand "build a comic viewer" tutorials. Then the tutorial-follower writes their first fetch() โ and hits this:
// in any web page:
fetch("https://xkcd.com/info.0.json").then(r => r.json())
// โถ Access to fetch at 'https://xkcd.com/info.0.json' from origin
// 'https://your-app.example' has been blocked by CORS policy: No
// 'Access-Control-Allow-Origin' header is present on the requested resource.
// โถ Uncaught (in promise) TypeError: Failed to fetch
Verified while writing this (September 2026): the comic JSON endpoints send no Access-Control-Allow-Origin header at all, and an OPTIONS preflight answers 405. The same curl works fine from a terminal โ the API is effectively server-side only. (Fun nuance: imgs.xkcd.com โ the images โ does send Access-Control-Allow-Origin: *. The pictures are CORS-open; the metadata about them isn't.) Two more walls tutorials hit right after: there's no search or list endpoint โ an archive or search feature means one request per comic, 3,300 of them as of September 2026 โ and comic #404 genuinely doesn't exist: https://xkcd.com/404/info.0.json returns an HTTP 404, a hole your random-comic button will eventually step in.
None of this is a complaint โ it's xkcd's site, serving its own front end, and it owes nobody CORS headers. But the standard workaround (routing a third-party CORS proxy like allorigins or corsproxy.io into your critical path) trades a CORS error for someone else's uptime and rate limits. For dev, CI, and browser apps, mock the shapes; for production, put the one-line proxy in your own backend.
Capture the comics you use, then reproduce the exact paths as custom routes. One curl creates a project, no signup:
curl -s https://xkcd.com/info.0.json > latest.json
curl -s https://xkcd.com/614/info.0.json > 614.json
curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects?name=xkcd-mock" | tee project.json
# โ {"id":"YOUR_ID","adminKey":"YOUR_ADMIN_KEY",โฆ}
jq '{path:"/info.0.json", body:.}' latest.json \
| curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
-H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' --data-binary @-
jq '{path:"/614/info.0.json", body:.}' 614.json \
| curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
-H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' --data-binary @-
Now fetch("https://mockbird.mockbird.workers.dev/m/YOUR_ID/info.0.json") works from a browser โ every /m/โฆ endpoint answers with Access-Control-Allow-Origin: * and real preflight handling. The body is the real response replayed byte-for-byte: num, title, safe_title, img, alt, transcript, year/month/day as the strings they actually are. And because the img URLs still point at imgs.xkcd.com โ which is CORS-open and hotlink-tolerant โ your viewer renders the real comics while the metadata comes from your mock. Bonus for tests: your "latest" is now pinned, so assertions about it never rot.
The classic random-comic implementation โ Math.ceil(Math.random() * latest.num) โ will sooner or later pick 404 and get an HTTP 404 with an HTML body (res.json() throws). Make the hole a permanent fixture and assert your handler survives it:
curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
-H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' \
-d '{"path":"/404/info.0.json","status":404,"contentType":"text/html",
"body":"<html>\r\n<head><title>404 Not Found</title></head>\r\n<body>\r\n<center><h1>404 Not Found</h1></center>\r\n<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n"}'
That's the real endpoint's 404 shape (HTML from nginx, not JSON โ we captured it). One test renders comic 614; another requests 404 and asserts the retry-with-a-different-number path. Both instant, both deterministic. Slow-connection skeletons and other failure modes are one query param away: ?mock_delay=3000, ?mock_status=500, ?mock_ratelimit=5.
If you're building archive, search, or favorites features, host comics as records instead of replayed URLs โ you get querying the real API can't do:
for n in 303 353 614 927 2347; do curl -s "https://xkcd.com/$n/info.0.json"; done \
| jq -s '{comics: .}' > db.json
curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/import?name=xkcd-archive" \
-H 'content-type: application/json' --data-binary @db.json
Every field is kept verbatim, and the full query toolkit applies: ?num=614 exact-matches, ?q=antigravity full-text-searches titles, alt text and transcripts (finds #353, the import antigravity comic), ?num_gte=900 ranges, ?sortBy=num&order=desc, ?select=num,safe_title,img projects a light list payload, X-Total-Count paginates, writes persist (a favorites flag is one PATCH away), and /m/YOUR_ID/types.ts generates a TypeScript Comic interface from your records (?format=zod for Zod).
| xkcd JSON API | Your mock | |
|---|---|---|
| The actual comics | โ all 3,300+, free, forever | only what you capture (but img URLs stay real) |
| Callable from a browser | โ no CORS headers, preflight 405 | โ Access-Control-Allow-Origin: * |
| Search / list / ranges | โ one request per comic | โ ?q=, filters, sort, pagination |
| Comic #404 | HTTP 404, HTML body | same โ as a fixture you can test on purpose |
| Deterministic for tests | โ "latest" changes 3ร/week | โ pinned until you change it |
| Failure drills (404/500/timeout/429) | โ | โ fixtures + mock_delay/mock_status/mock_ratelimit |
| Right for | server-side fetching, via your own backend/proxy | browser apps in dev, CI, demos, workshops |
The split that's fair to everyone: in production, fetch xkcd from your own backend (or a one-line edge function) and cache it โ comics change three times a week, so cache hard. In dev and CI, point the browser at /m/YOUR_ID and stop debugging CORS errors that aren't yours to fix. Current status of the real API: /status/xkcd. Related: Rick and Morty API, Studio Ghibli API, Dog API, mock any third-party API.