A full Medium-style blogging platform — the RealWorld spec — built to practice the parts of a real app a to-do list never teaches: authentication, an infinite feed, a markdown editor, and a data layer that refuses to trust the network.
Highlights
Implemented every screen of the RealWorld spec against a shared public API — sign-in and sign-up, profiles, favoriting, the markdown editor, and an infinite-scrolling article feed.
Validated every API response at runtime with io-ts codecs, so a malformed payload becomes a typed, recoverable error instead of an undefined crashing three components deep.
Modeled the data layer with fp-ts: requests resolve to an Either, expected failures travel as values, and only the genuinely unexpected is ever thrown.
Wrapped Radix primitives into a small, accessible component library and documented each one in Storybook, deployed on its own.
Conduit is the RealWorld app — a
Medium-style blogging platform that hundreds of developers rebuild to compare
stacks. The spec hands you the design and a public API; what you bring is the
front end. I took it on for one reason: I wanted to practice the parts of a real
application that a to-do list quietly skips.
A to-do list lies to you
Most starter projects render some local state and call it a day. A real product
has to authenticate a user, page through data that doesn't fit on one screen,
accept long-form input, render untrusted markdown, and — the part nobody
mentions — survive a server that occasionally lies to it. Conduit has all of
those, against an API I didn't write and couldn't change. That last constraint
is the whole point: the network is a boundary I don't control, so I decided to
treat it like one.
Don't trust the network — decode it
By the time a JSON response reaches a component, TypeScript has already stopped
helping. response.json() is any; the interface you wrote over it is a hopeful
fiction. So every response passes through an
io-ts codec before anything downstream sees
it:
export const ArticleCodec = type({ slug: withMessage(NonEmptyString, () => "slug should be a non-empty string"), title: withMessage(NonEmptyString, () => "title should be a non-empty string"), body: withMessage(NonEmptyString, () => "body should be a non-empty string"), author: AuthorCodec, favoritesCount: withMessage(number, () => "favoritesCount should be a number"),})
The codec is the single source of truth: the runtime check and the static type
are derived from the same definition, so they can't drift apart. fetcher runs
the decode and hands back an Either — a value that is either a validation
error or the data, never a surprise:
A malformed payload becomes a typed ValidationError I can render as an honest
error state — not an undefined that detonates three components deep. I liked
this pattern enough to write it up on its own, in
Handling errors in TypeScript.
Failures are values, not exceptions
Once fetcher returns an Either, the rest of the app stops reaching for
try/catch to steer. Expected failures — a 422, an expired token, an empty
result — travel as data; only the genuinely unexpected is thrown. Pagination is
the nicest example: the feed is an infinite query, and "is there a next page?"
is a pure function of the last response.
getNextPageParam: (lastPage) => pipe( lastPage, match( () => null, // a failed page has no next page ({ articles, articlesCount }) => articles.length === articlesCount ? null // every article is loaded : { limit: String(articles.length + defaultArticlesLimit) }, ), )
No mutable counters, no off-by-one. React Query caches the result and an
IntersectionObserver asks for the next page when the sentinel scrolls into
view. I leaned on fp-ts heavily here — more of that thinking is in
Functional programming in JavaScript.
Components I'd actually reuse
The UI is a small library, not a pile of one-offs. Dialogs, tabs, popovers, and
toasts are built on Radix primitives, so focus
traps and keyboard behavior are handled by people who've thought about them
harder than I have. Each component is documented in
Storybook and themed with Styled
Components. Auth is a cookie-backed state machine — idle → loggedIn → loggedOut — so the UI never has to guess whether it knows who you are.
A to-do app teaches you React. A spec like RealWorld teaches you the parts of
a real app everyone skips — and the ones that actually break in production.
What I'd do differently
fp-ts and io-ts did exactly what I asked of them, but they're a steep ask
for anyone joining the codebase, and the ecosystem has moved since. Today I'd
reach for Zod to decode and something lighter —
neverthrow, or Effect, where a lot of the fp-ts
energy went — for the Either flow. I'd also collapse the duplication between
my list and detail codecs by composing one from the other, and lift the auth
token out of a cookie that's re-read on every request into a single source.
None of that changes the lesson, though: validate at the edges, model failure as
a value, and the inside of the app gets quiet.