tl;dr Phoenix lover adventuring through NodeJS greatly misses the OTP promised-land, and is happy to be visiting Elixirland. He also concludes full-stack JS applications in 2023 still suffer from the same systemic JS problems they had 5-10 years ago.
After a year in Nodeland (where I still reside full-time), I am getting back up to speed on Phoenix 1.7 and the latest LiveView, and I am so happy to be back.
Quick background: I’ve been building Elixir (mostly Phoenix) web applications and mobile APIs since 2017, but over the past year I’ve been exclusively back in NodeJS NextJS land with TypeScript.
My most significant Phoenix project was leading a small team building a healthcare Phoenix 1.4 app with a VueJS frontend, and after that I built some LiveView applications in the days before HEEX templates with Tailwind 2.X. I still have some applications running on Fly that I don’t even touch.
About a year ago, I was recruited with an offer I couldn’t refuse to be the first engineering hire at a NYC-based start-up that was still considering its stack. The VC studio that created the start-up was all-in on full-stack TypeScript NodeJS with NextJS, tRPC, Prisma, and MaterialUI React components, a stack that was cutting-edge some years ago (besides MaterialUI, which was cutting edge ten years ago). Fortunately my colleagues and I were able to steer our company’s leadership into Tailwind, and we were able to build a prototype very quickly using Vercel, an auth-as-a-service provider, and a super database-as-a-service written in Elixir.
I wrote some NodeJS servers, ReactJS web apps, and React Native iOS/android apps in JS rather than TS back in the end-times of Redux and the early days of functional components (with Context but before the days of useState and useEffect), and the 2022 developer experience setting up and deploying a TypeScript-based NextJS application through Vercel with third-party services was first-class. I had the impression that JS had matured in recent years for rapid prototyping and hiring – the latter being the most important aspect for some, as it is very easy to find NodeJS engineers.
As our system grew and required more sophisticated CRUD and product changes, the mask fell off of modern JS, revealing the same lumbering build-your-own-framework, held-together-by-popsicle-sticks time-consuming beast I knew last decade. It is a good thing there are so many NodeJS engineers out there, as you’re going to need them when you’re building a NodeJS team.
So, without further ado, here is a rundown of the good and the bad of my experience building from scratch in NodeJS with NextJS in 2022-23.
Basic CRUD
The Good
- React provides a great experience when it comes to re-usable UI form components. It took me a few days to write a small library of accessible, easily styled form components with props for
react-hook-form
controls that were intuitive enough to be adopted by my team without training. -
React-hook-forms
provides a much better experience when it comes to dynamically managing React form state than the bespoke React form state management of olde.
The Very Bad
- Frameworks such as Phoenix and RoR provide validation and CRUD out of the box. I’ve seen people in Reactland spend two weeks building something Phoenix, Ruby, and even WordPress can provide you with in about 30 minutes.
- Bespoke frontend validation stitching together third-party resolvers such as
zod
,yup
is just… such a manual timesink. - Things like toasts and error displays also have to be stitched together manually, as with everything else when you’re using a NodeJS+React stack.
TypeScript
The Good
- Types in TS provide great introspection into arguments, functionality, and developer intention so code becomes more maintainable across larger teams.
- TypeScript also provides psychological security and continuity to the many, many C# programmers who escaped from .NET into NodeJS in the last decade, among others.
The Bad
- TypeScript requires that you build and maintain your own type system, handling plenty of gotchas such as decimals, and plenty of object mutation you otherwise would not need to do between frontend and backend in other languages with either type support or attribute casting.
- It is a good thing the market for TypeScript developers is so deep, as broad systems will require that you hire a full-time employee to maintain your bespoke type system rather than, you know, build user experiences.
- Undefined and null are still handled clumsily in JS validation libraries such as zod. In much of the civilized web dev world, we don’t need to care if a value is undefined or null if it isn’t required at all; much of the TypeScript world disagrees. Many such cases of this sort of compulsive, unnecessary end-to-end type wrangling in TS.
Testing, Deployment & CI/CD
The Very Great:
- NodeJS-based applications are the easiest web applications to deploy to production. From Vercel to Netlify to Heroku and beyond, the process of deploying a “serverless” Node web application with whatever frontend JS library you like is a two-step process: connect remote git, press deploy.
- NodeJS CI/CD methodologies and services are many and mature, whether you’re going with old-school CI/CD runner services or GitHub actions and the like.
The Not-so-great
- Testing in NodeJS is still a weird bespoke process, and likely always will be as long as full-stack NodeJS applications are Frankensteins of stitched-together libraries rather than actual frameworks.
- There are plenty of great e2e JS testing frameworks such as Cypress and Playwright, and Jest gets the job done at a simple level, but achieving wide-ranging unit and integration tests may require changes to component architecture that make application state less efficient and more difficult to manage, especially if you are getting your state from a query client that is difficult to mock.
- Testing pipelines are also weirdly bespoke and time-consuming, even if you’re using default ORM methods of seeding data. (Nothing beats Elixir and Phoenix-land when it comes to testing out-of-the-box IMO.)
ReactQuery / tRPC
The Good
- The very clear client-server relationship between client-side ReactQuery
useQuery
/useMutation
and tRPC routers provides a nice request structure out-of-the-box that is easy to collaborate on, even with front-end folks who are new to backend. - tRPC routers (collections of request endpoints ) make your NodeJS application feel like it has a logical framework.
- tRPC and its patterns provide a much, much better experience for team backend development than classic Express servers and the like.
The Bad
- tRPC enables ReactQuery functionality such as refresh-on-window-refocus by default to seemingly fake live data, which is terrible if you have forms downstream of a query that will refresh unexpectedly and reload default state.
- tRPC docs are sparse when it comes to obscure ReactQuery config and functionality, without clear linkage; you have to learn the ins-and-outs ReactQuery independently of tRPC if you really want tRPC to work properly.
- tRPC bills itself as an alternative to GraphQL, and it most certainly is not. tRPC does not allow you to compose the data a request returns in your query, the entire selling point of GraphQL – with tRPC you still have to compose your data manually on a backend route if you want such functionality, making it in many ways just another abstract HTTP client. While I appreciate the work the tRPC team puts into the library, and how accessible their support is, tRPC is not an alternative to GraphQL, it is a framework for building ReactQuery HTTP request clients/servers; the similarities between GraphQL and tRPC begin and end with calling read requests “queries” and create/edit/delete requests “mutations.”
Overall (aka misc venting and grunting noises)
The Good
- NodeJS is still accessible, easy to set up, well documented, easy to start learning.
- ReactContext is far more accessible than Redux for communicating state between components. I don’t care if you’re a lover of the Redux pattern or the component architecture it forces you into, Redux is its own discipline that takes a long time for junior developers to learn with little-to-no payoff.
- TanStack’s ReactQuery is a query state management library that brings a lot of structure and functionality to query state/caching/etc out-of-the-box. This was definitely missing from Reactland half a decade ago.
- Reactive (event-driven?) JS libraries such as RxJS may be more effective alternatives to the kind of stack described herein.
The Bad (big vent)
- In 2023, React developers are still fighting the event loop. While functional components require much less code than the classic React class component, less experienced developers now spend the time they used to spend writing step-by-step class component lifecycles fighting obscure errors resulting from mystery state events, re-renders, and clumsy useEffects.
- Full-stack NodeJS applications quickly become very slow, especially when relying on third-party services for things like auth. The time you save building and deploying a prototype very quickly in NodeJS+NextJS will almost certainly be spent cleaning up technical debt and product debt down the line.
- Clearing technical debt in a ReactJS app is a high-wire act thanks to the obscurity possible in the many methods of establishing state and managing lifecycle. Clearing tech and product debt in any language/framework/situation may require serious architectural changes, often ground-up ones, but never have I experienced such churn as I have in React NodeJS-land.
- NextJS is “SSR”, but you’re still sending massive SPA bundles to the client that just look more like SSR pages to search engines and developers. This obscurity can be problematic if you expect a full state changes at the layout-level of an application without switching layouts, and no amount of telling NextJS to “push” to new routes with data refreshes will consistently get you a full refresh, which to me is not just bad syntax, it’s misleading.
- An uncaught error will still take down your NodeJS server in 2023.
I’ll stop there. You may disagree with some of the conclusions drawn from my observations, but keep in mind that this grouse-fest is not drawn just from my experience but the experiences of my TypeScript NodeJS engineering colleagues.
The conclusion I would like to lean into is JavaScript is just not a productive language for full-stack software systems. I realize I may be preaching to the choir in a forum dedicated to a language and frameworks created by explicitly anti-JS engineers, and I still believe micro-frontends with ReactJS/VueJS/whatever are great solutions for teams with diverse or limited skillsets.
My impression a couple years ago was that LiveView had become production-ready but was still missing simple component-composability, and I still leaned on Channels for things such as layout-level notifications (which I may still do). As I re-engage with Phoenix and LiveView, my impression is the latest LiveView is not only more composable, but more mature, both syntactically and functionally. I look forward to discovering Elixir and Phoenix’s latest capabilities and quirks as I make my way home.