Credit first, because it's due: the official Hacker News API is everything a free public API should be. No key, no signup, real CORS headers (access-control-allow-origin: * โ browser fetch() just works), hosted on Firebase, and in years of "build an HN reader" tutorials nobody's ever had to write the "is it down?" paragraph. That's why it's the default first-API exercise for React, Vue, and Swift alike.
Then you hit its shape. It's a Firebase realtime database export, and Firebase thinks in single keys โ so the entire query surface is "give me one thing by id" (verified September 2026):
curl https://hacker-news.firebaseio.com/v0/topstories.json
# โ [49763697, 49763928, 49763987, โฆ] 500 ids. Just ids.
curl https://hacker-news.firebaseio.com/v0/item/49763697.json
# โ the actual story โ one request per id
curl "https://hacker-news.firebaseio.com/v0/item/49763697,49763928.json"
# โ null โ no batch endpoint
curl "https://hacker-news.firebaseio.com/v0/item.json?orderBy=%22score%22"
# โ {"error": "Permission denied"} โ no queries on items, ever
So a 30-story front page is 31 HTTP requests โ the id list, then N+1 waterfall through every story. There's no search, no "stories over 200 points", no field selection, and no sorting beyond the order of the id array. Every HN-reader tutorial quietly becomes a lesson in Promise.all before it gets to render anything.
Also genuinely good, also free and keyless and CORS-open: hn.algolia.com/api/v1 indexes all of HN and returns full story objects, thirty at a time, in one request (verified September 2026):
curl "https://hn.algolia.com/api/v1/search?tags=front_page"
# โ the whole front page, one request, with title/points/author/num_comments
curl "https://hn.algolia.com/api/v1/search?tags=story&numericFilters=points>500"
# โ range filters work too
Where you'll hit its edges: results come in exactly two orderings โ relevance (/search) or date (/search_by_date) โ so "front page sorted by points" is client-side sorting again; every hit carries a _highlightResult block several times the size of the story itself; the shapes differ from the Firebase items (string objectID, different field names); and it's read-only, which is fine until your exercise adds an upvote button.
The top 30 stories are ~11KB of JSON. Capture them once and host your own copy โ no signup:
curl -s https://hacker-news.firebaseio.com/v0/topstories.json |
jq -r '.[:30][]' | while read id; do
curl -s "https://hacker-news.firebaseio.com/v0/item/$id.json"
done | jq -s '{stories: map(del(.kids))}' > db.json
curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/import?name=hn" \
-H 'content-type: application/json' --data-binary @db.json
# โ {"id":"YOUR_ID","adminKey":"YOUR_ADMIN_KEY",
# "resources":[{"name":"stories","records":30,โฆ}]}
(We drop kids โ comment ids you didn't capture โ but keep everything else.) Because HN ids are plain integers, the importer preserves them verbatim: /stories/49763697 resolves with the real HN id, so deep links and item URLs built from your data still make sense.
All of these ran against a live import while writing this guide:
BASE=https://mockbird.mockbird.workers.dev/m/YOUR_ID
curl "$BASE/stories?sortBy=score&order=desc&limit=3&select=title,score"
# โ the actual top-by-points list neither upstream can give you sorted
curl "$BASE/stories?score_gte=200" # range filter โ X-Total-Count: 8
curl "$BASE/stories?q=ai" # full-text across every field
curl "$BASE/stories?title_like=cloudflare" # substring, case-insensitive
curl "$BASE/stories?by=emigre" # exact author match
And writes are real, which is what an HN-client exercise actually needs:
curl -X PATCH "$BASE/stories/49763697" \
-H 'content-type: application/json' -d '{"voted":true}'
# โ your upvote button has a backend; GET-back shows voted:true
curl -X POST "$BASE/stories" -H 'content-type: application/json' \
-d '{"title":"Show HN: my thing","by":"me","score":1,"type":"story"}'
# โ persists โ X-Total-Count is now 31
/m/YOUR_ID/types.ts generates a TypeScript Story interface from your records (?format=zod for Zod), and failure drills are a query param away: ?mock_delay=3000, ?mock_status=503, ?mock_ratelimit=5.
One more thing a snapshot buys you: the real front page won't hold still. While writing this guide, two captures ten minutes apart contained the same 30 stories in a different order. A live-HN test suite or a screenshot test drifts by the minute; a snapshot is the same 30 stories, in the same order, every run โ and named snapshots let you keep an "empty", an "ask-hn-heavy", and a "300-comment-thread" scenario side by side.
| Firebase (official) | Algolia HN Search | Your snapshot | |
|---|---|---|---|
| Live data | โ the real thing | โ indexed, searchable | โ frozen at capture (that's the point) |
| Front page | 31 requests | โ 1 request | โ 1 request |
| Search | โ none | โ excellent โ its whole job | โ ?q=, ?title_like= |
| Sort by points | โ | โ relevance or date only | โ ?sortBy=score&order=desc |
| Range filters | โ | โ numericFilters | โ ?score_gte= |
| Field selection | โ | โ (+_highlightResult bloat) | โ ?select=title,score |
| Writes persist | โ read-only | โ read-only | โ POST/PATCH/DELETE are real |
| Deterministic for tests | โ reshuffles constantly | โ | โ same data every run |
The fair split: a real HN reader that people use should read the real API โ Firebase for item detail, Algolia for lists and search, and both are excellent at what they chose to be. The moment you're building or testing that reader โ sorting by points, wiring an upvote button, asserting on a fixed page of stories โ spend the one curl and own the copy. Related: Rick and Morty API, PokรฉAPI, deterministic test data, mock any third-party API.