Stack
What I build with, and what I built
You already know what React is, so this is not that page. Every tool below has what I shipped with it, the decision behind it, and what I learned the expensive way.
React.js
Web · Since 2017
React is where most of my web work happens, and has been since 2017. Everything from marketing pages to dashboards dense enough that render cost stops being an abstract concern.
Why I reach for it
The thing React got right is that a UI is a function of state, and the hard part of front-end work is deciding what the state actually is. Most of the bugs I've chased in other people's React code weren't render bugs — they were the same piece of truth stored in three places, drifting apart. So the work is mostly drawing component boundaries where the data already has seams, and keeping state as close to where it's used as it will go.
In practice
A dashboard slowed to a crawl once its table passed a few thousand rows. The cause was not missing memoisation — it was a filter object rebuilt inline on every render, which broke every comparison downstream.
What I learned — Profile before memoising. React.memo on a component receiving a fresh object each render buys nothing; the fix is upstream, in what the parent passes.
A form kept its values in three places: the input state, a draft for the preview, and the server response. Every bug filed against it was the same bug — the three drifting apart.
What I learned — Most React bugs I have chased were not render bugs. They were one piece of truth stored more than once.
react-multi-carousel had a 2kb budget and had to render correctly on the server, so first paint could not depend on measuring the DOM.
What I learned — A component that renders before it can measure needs a sensible layout without JavaScript. That constraint made it better on the client too.
What I've built with it
- Client dashboards with tables and filters large enough that memoisation and list virtualisation were load-bearing, not premature
- Design-system components with accessibility built in from the start — roles, labels and keyboard behaviour, rather than bolted on after a review
- A carousel library contribution (react-multi-carousel) where SSR correctness and a 2kb budget shaped every decision
- Server Components in Next.js, which changed where I put data fetching more than any React release since hooks
Open source built with React.js
Questions I get asked
How long have you worked with React?
Since 2017 — marketing sites, dashboards dense enough that render cost mattered, and design-system components.
When do you reach for a state library instead of useState?
When a value is genuinely needed by parts of the tree with no sensible shared parent. Server state usually belongs in a data-fetching cache instead.
Alongside
React Native
Mobile · Since 2018
React Native is the centre of my mobile work — iOS and Android from a single codebase, and the ecosystem I've put the most open source time into by a wide margin.
Why I reach for it
Sharing a codebase across two platforms is the obvious argument, but the one that matters more to me is that the whole team stays in one language and one mental model. The honest trade-off is that you inherit the native layer anyway: build config, permissions, and the occasional native module that has to be read rather than merely installed. Knowing where that line sits is most of the skill.
In practice
The drawer navigator had no way to disable its swipe gesture, so it fought any horizontally scrolling screen. I added the swipeEnabled option to React Navigation.
What I learned — Gesture conflicts are the most common React Native complaint that is not a bug. Two handlers want the same drag; the fix is deciding which one owns it.
Triage across 300+ React Navigation threads and 120+ on React Native Paper. The same few causes explain most reports: a native module not rebuilt, a version mismatch, or a missing gesture handler at the root.
What I learned — Nearly every "React Native is broken" issue is an environment issue. Reading the build output beats reading the JavaScript.
Root Native UI ships 28 typed Material Design 3 components, and runs on both Expo and bare React Native.
What I learned — Supporting both means no native code Expo cannot express as a config plugin. That pushed the library toward JavaScript and Reanimated — the more portable design anyway.
What I've built with it
- Cross-platform apps shipped to both the App Store and Play Store, from data model through to release
- Contributor to React Navigation — the swipeEnabled drawer option, and triage across 300+ issue threads
- Contributor to Callstack's React Native Paper — 11 merged PRs, plus triage and support across 120+ issue threads
- Author of Root Native UI: 28 typed components shipping Material Design 3 on Expo and bare React Native
- Author of Inertia, a Reanimated 4 wrapper that turns animations into props
- The Stack Overflow questions I answer most are React Native ones — navigation, Expo builds and native module setup
Open source built with React Native
Writing about React Native
Questions I get asked
Is React Native still worth it over native development?
For most product work, yes — one team, one language, two shipped platforms. The trade is that you inherit the native layer: build config, permissions, and the occasional module you read rather than install.
What breaks most often in a React Native project?
The build, not the JavaScript. A native dependency added without a rebuild, mismatched versions, or iOS signing.
How do you handle animations in React Native?
Reanimated, on the UI thread. I wrote Inertia to make that easier — it turns animations into props, so a component animates without a hook and a shared value per property.
Alongside
Node.js
Backend · Since 2017
Node is what sits underneath the apps I build — REST and GraphQL APIs, background jobs, and the build and release tooling behind the libraries I publish.
Why I reach for it
One language across the client and the server is worth more in practice than it sounds on paper: types can be shared, validation logic doesn't get written twice, and context-switching cost drops to nearly zero. Node is also simply the right shape for what most of my work needs — IO-bound request handling rather than CPU-bound computation.
In practice
An endpoint returned errors in three shapes depending on which layer failed: a validation object, a thrown string, and a bare 500. Every client special-cased all three.
What I learned — Design the error shape as deliberately as the success shape. One envelope, one place that builds it, and clients stop guessing.
Publishing packages with per-component subpath exports made the entry points public API. A wrong exports map broke consumers only in their bundler, never in mine.
What I learned — Test the published artifact, not the source. Installing the packed tarball into a scratch project catches what local testing cannot.
Building the rootnative CLI — npx rootnative create to scaffold, rootnative add to copy components in with their source.
What I learned — Copying source into the user's project instead of hiding it behind a dependency lets them edit it. For a UI library that is the feature.
What I've built with it
- REST and GraphQL APIs backing web and mobile clients, with auth, validation and error shapes designed rather than improvised
- Next.js API routes and Server Actions where an endpoint only exists to serve one app
- npm package publishing with per-component subpath exports and bundle size checked in CI
- CLI tooling — npx rootnative create to scaffold a project, and rootnative add to copy components in with their source
- Scripts and cron jobs for the unglamorous parts: data migration, report generation, scheduled cleanup
Questions I get asked
Why Node on the server when you also write TypeScript on the client?
That is the reason. Types get shared, validation is written once, and switching between the two halves of a feature costs nothing.
REST or GraphQL for a new project?
REST, unless something specific argues otherwise. GraphQL earns its complexity when many screens need different slices of one graph.
Alongside
Expo
Mobile · Since 2019
Expo is how I build, sign and ship React Native apps, and how I get a fix to users without waiting on a review queue.
Why I reach for it
Expo's real value is that it removes the two worst days of any React Native project: the first one, spent on Xcode and Gradle config, and the one much later when a signing certificate expires at the wrong moment. The old objection — that you'd hit a ceiling the moment you needed a native module — stopped applying once development builds and config plugins landed. You get the managed workflow's ergonomics without giving up the native layer.
In practice
A release was blocked because the only machine with working signing certificates belonged to someone on leave. EAS Build made releases depend on the project instead.
What I learned — Build infrastructure on a developer's laptop is an outage waiting for a holiday.
A JavaScript-only bug reached production the day before the review queue slowed down. An over-the-air update fixed it that afternoon.
What I learned — Know which fixes are JavaScript-only before you need to. Anything touching native code still waits for a store release.
Keeping ios/ and android/ out of version control by expressing native changes as config plugins.
What I learned — Once native folders are committed, every upgrade becomes a merge conflict against generated code.
What I've built with it
- EAS Build and Submit for iOS and Android releases, so a build doesn't depend on one particular laptop being set up correctly
- Over-the-air updates to push JS-only fixes straight to users, skipping the store review cycle
- Config plugins to inject native changes at prebuild time, keeping ios/ and android/ out of version control
- Root Native UI is built to run on both Expo and bare React Native — the library has to work either way
Open source built with Expo
Writing about Expo
Questions I get asked
Does Expo still limit you when you need native modules?
No. That stopped applying once development builds and config plugins landed — you install the module, run a development build, and keep the managed workflow.
When would you not use Expo?
When a project already has a heavily customised native layer nobody wants to express as plugins. Staying bare is sometimes cheaper.
Alongside
TypeScript
Language · Since 2018
TypeScript is the default for everything I write — apps, libraries and the scripts around them. Every library I publish ships its own types.
Why I reach for it
The value isn't catching typos; a test suite does that. It's that designing the types forces the modelling decision to the front, where it's cheap. A union of valid states rules out the impossible combinations that otherwise become runtime bugs six months later. Strict mode from the first commit, too — retrofitting it onto a codebase that grew up loose is a project of its own.
In practice
In Inertia, style types are inferred per primitive: Motion.View takes a ViewStyle, Motion.Text a TextStyle. The wrong one fails at compile time instead of being silently ignored.
What I learned — The best types enforce what was already true. React Native drops a mismatched style silently — exactly the mistake a type should catch.
Migrating JavaScript codebases module by module, rather than in one branch that could never be merged.
What I learned — Incremental migration is the only kind that finishes. The boundary moves when someone touches a file, not on a schedule nobody owns.
Sharing types between a Node API and its React client, so a changed response shape breaks the build.
What I learned — A shared type is a contract with a test that runs on every build.
What I've built with it
- Style types inferred per primitive in Inertia, so Motion.View takes ViewStyle and Motion.Text takes TextStyle — passing the wrong one fails at compile time
- Strict types on every prop and theme token across Root Native UI's 28 components
- Shared types between a Node API and its React client, so a response shape change breaks the build instead of the app
- Migrating JavaScript codebases to TypeScript incrementally, module by module, rather than in one unmergeable branch
Open source built with TypeScript
Writing about TypeScript
Questions I get asked
Strict mode from the start, or add it later?
From the first commit. Retrofitting strict onto a loose codebase is a project of its own, competing with feature work it will lose to.
What is the most useful TypeScript feature for application code?
Discriminated unions. Modelling state as a union of valid shapes rules out the impossible combinations — loading with data, error with a result.
Alongside
Next.js
Web · Since 2020
Next.js is my default for anything on the web that needs to be found by search engines or fast on first load. This site is a Next.js App Router app, statically rendered.
Why I reach for it
Client-only React makes you choose between a good developer experience and a page a crawler can actually read. Next.js removes the choice: Server Components fetch data where the data lives, static rendering turns pages into plain HTML at build time, and the metadata API keeps titles, canonicals and Open Graph tags next to the pages they describe instead of in a separate head-management layer.
In practice
This site: statically rendered App Router pages, JSON-LD on each one, and a sitemap generated from the same data the pages are built from.
What I learned — Generate the sitemap from the content source, never by hand. A page with no entry, or an entry with no page, is the failure you never notice.
Consolidating this stack section from nine thin pages into one. Each tool had roughly 150 words — enough to be a URL, not enough to be worth reading.
What I learned — More URLs is not more search presence. Nine pages restating what a tool is compete with its documentation and lose.
Per-page metadata through generateMetadata, including finding that setting an images array suppresses the adjacent opengraph-image file.
What I learned — The metadata API merges less than you expect. Fields replace their parent rather than combining.
What I've built with it
- This site: statically rendered App Router pages, generateStaticParams for every project and stack page, and JSON-LD structured data on each one
- generateMetadata for per-page titles, descriptions, canonicals and Open Graph images
- Server Components and Server Actions to keep data fetching and mutations off the client
- Route handlers where an app needs its own small API rather than a separate service
- Sitemap and robots generated from the same data the pages are built from, so a new page can't go unlisted
Questions I get asked
Why Next.js instead of plain React?
Client-only React makes you choose between a good developer experience and a page a crawler can read. Next.js removes the choice.
App Router or Pages Router for a new project?
App Router. Server Components change where data fetching belongs more than any React release since hooks.
Alongside
GraphQL
Backend · Since 2019
GraphQL is how I've fetched data on most client projects since 2019 — usually with Hasura serving the schema, sometimes with a hand-written resolver layer over an existing service.
Why I reach for it
Its clearest win is on mobile, where round trips are expensive and unreliable. A screen asks for exactly the fields it renders, in one request, and the schema is introspectable so the client types generate themselves. The cost is real though — resolver-level N+1 queries and unbounded nesting are easy to write and easy to miss until load arrives. For a small app with two endpoints, REST is still the honest answer.
In practice
A screen that looked cheap issued one query per row once it reached the resolver layer. Nothing in the query document hinted at it.
What I learned — N+1 is the default failure of a resolver layer, not an edge case. Batch at the data-loading layer from the start.
Designing schemas around what screens need, rather than mirroring database tables one to one.
What I learned — A schema that mirrors your tables pushes every join into the client.
Putting authorization in Hasura's permissions and row-level rules instead of in each client.
What I learned — Authorization duplicated per client will eventually disagree with itself.
What I've built with it
- Schema design for client apps, modelling around what screens need rather than mirroring database tables one-to-one
- Generated typed hooks from the schema, so a field rename surfaces as a compile error rather than an undefined at runtime
- Hasura permissions and row-level rules to keep authorization in the data layer instead of duplicated per client
- Cursor pagination and query depth limits on the endpoints that needed protecting
Questions I get asked
When is GraphQL not the right choice?
A small app with two endpoints. A schema, a resolver layer and a client cache all have to be paid for.
Why does GraphQL matter more on mobile?
Round trips are expensive and unreliable on a phone. One request returning exactly what a screen renders is a much bigger win there.
Alongside
Firebase
Data · Since 2018
Firebase covers the parts of an app that are the same every time — sign-in, a realtime document store, push notifications, crash reporting — so a small project doesn't need a backend team to get moving.
Why I reach for it
For an early-stage product, Firebase Auth alone saves weeks that would otherwise go into password resets, email verification and OAuth callbacks. Firestore's realtime listeners fit the mobile case well: the UI subscribes and stays current without polling. The limits are real and worth knowing up front — no joins, and queries have to be designed alongside the documents rather than after them. When the data turns relational I move to Postgres and stop fighting it.
In practice
A Firestore model designed like a relational schema needed four reads to render one screen. Reshaping it around the read pattern brought it to one.
What I learned — Firestore rewards designing documents and queries together. Model for what the screen reads, and accept the duplication.
Push notifications on iOS, where APNs certificate setup is the step most projects get stuck on.
What I learned — Almost every "push does not work on iOS" report is certificates, not code. Verify the token reaches the server first.
Treating security rules as the authorization boundary, not as a backup for client-side checks.
What I learned — A client-side check is user experience. If the rules do not enforce it, the data is public.
What I've built with it
- Authentication with email, Google and phone sign-in across React and React Native clients
- Firestore models shaped around read patterns, with denormalisation where the alternative was a join the database doesn't have
- Cloud Messaging for push notifications on iOS and Android, including the APNs certificate setup people usually get stuck on
- Security rules as the actual authorization boundary — a client-side check is UX, not enforcement
- Crashlytics on release builds, so a production crash arrives with a usable stack trace
Questions I get asked
When do you move off Firebase?
When the data turns relational. No joins is a real constraint, and once queries need them I move to Postgres.
Firebase or Supabase for a new app?
Supabase when the data is relational, which it usually is. Firebase when sign-in, realtime listeners and push need to work today with no backend team.
Alongside
Docker
Tooling · Since 2019
Docker is how I keep an environment the same in three places that otherwise drift: my machine, CI, and the server it eventually runs on.
Why I reach for it
"Works on my machine" is nearly always a version difference nobody wrote down. A Dockerfile writes it down. The practical wins are multi-stage builds — build dependencies stay out of the final image, which drops it from hundreds of megabytes to tens — and Compose, which means a new contributor gets Postgres running with one command instead of a page of setup instructions.
In practice
A Node image shipping at several hundred megabytes because build dependencies stayed in the final layer. A multi-stage build cut it to tens.
What I learned — The build stage and the runtime stage want different things.
Compose files bringing up Postgres, Redis and the app together, so a new contributor runs one command instead of a page of setup steps.
What I learned — Setup instructions rot silently because nobody re-reads them. A Compose file breaks loudly.
Pinning base images and running as a non-root user.
What I learned — latest is not a version — it is whatever was pushed most recently.
What I've built with it
- Multi-stage Dockerfiles for Node services, so the shipped image carries the runtime and nothing that built it
- Compose files spinning up Postgres, Redis and the app together for local development
- The same image built in CI and deployed, so what passed the tests is what runs
- Pinned base images and non-root users, because latest is not a version and root is not a requirement
Questions I get asked
Why bother with Docker on a small project?
Because "works on my machine" is nearly always a version difference nobody wrote down. A Dockerfile writes it down.
Alongside
Also in the toolbox
Tools I use often enough to name, but that have not given me a story worth a section yet.
See these tools in use — read the open source work or the writing.
Get in touch →