I recently experimented with using Hotwire, the HTML-over-the-wire approach that comes default with the Ruby on Rails framework but with a node.js based backend. My goal was to try the paradigm out using a language I am familiar with, Typescript, to determine if this is a better developer experience and paradigm in general to build web applications with.
If you haven't seen part one for the initial setup and tour, check it out here: Part I: How to Build an App with Nestjs and Hotwire
Implementing Hotwire with NestJS
If you want more details on the initial setup I encourage you to take a look at the Part I that covers more of the initial implementation. For this portion, I added Prisma as an ORM, a frontend style library called Tachyons, and AlpineJS to handle any client-side interactions. I did this to avoid needing to add a client-side bundler to the build and instead just rely on plain old module imports to compose the frontend. This is now the default for Rails and it is quite nice to not need any additional build tools for the client.
I chose Tachyons over Tailwind because Tachyons is an atomic CSS framework, similar to Tailwind, however it's much lighter weight. Tailwind tends to be a bit heavier without using post CSS processing so I wanted to stick with something smaller.
I chose AlpineJS over the Hotwire default of Stimulus because Alpine does provide a lot of nice modules and features like modals and focus lock for accessibility that can be a pain to implement on your own. I think Alpine really has some great features that you will appreciate if you were going to build something more robust with this.
Broadcasting Updates and Security Concerns
A key benefit of Hotwire is streaming updates to multiple connected clients. However, this can also be a security risk. The vast majority of apps I've worked on do not require this type of functionality so broadcasting every user's updates to every connected client is a security risk in most cases.
You could potentially avoid using turbo streams and just return the right HTML in the standard response of your API calls via turbo frames. The problem here is if an update requires you to update multiple components or frames on the page you would either need to specify the target=_top
property and refresh all the frames. However, this method could become heavy. Turbo streams allow us to target specific elements and update them by DOM selector.
For example here we update the messages list as well as the messages count at the top of the page in a single turbo stream event:
<turbo-stream action="append" targets="#messages ul">
<template>
<li id="message_{{message.id}}_list_item" class="bb pv3">
<turbo-frame id="message_{{message.id}}">
{{> message message=message}}
</turbo-frame>
</li>
</template>
</turbo-stream>
<turbo-stream action="update" target="messages-count">
<template>
<turbo-frame id="messages-count">
<strong class="db mv3">Messages Count:
{{messagesCount}}</strong>
</turbo-frame>
</template>
There are really three core scenarios we need to cover:
- Updating only the user's client
- Updating everyone's client
- Updating only select authorized clients
I came decided to implement a broadcastTo
flag that allowed for broadcasting updates to self
, all
, or with-permissions
. The last type would allow you to pass a predicate function to the controller where you could execute your authorization logic. As this is just a simple demo I left the with-permissions
and predicate function for a later day.
return fromEvent(this.eventEmitter, 'turbo-stream.event').pipe(
map(
(payload: {
template: string;
requestStreamId: string;
broadcastTo: 'self' | 'all' | 'with-permissions';
predicate?: (req: Request) => Promise<boolean>;
}) => {
switch (payload.broadcastTo) {
case 'all':
return {
data: payload.template,
} as MessageEvent;
case 'self':
if (
!!payload.requestStreamId &&
!!req.cookies['x-turbo-stream-id'] &&
payload.requestStreamId === req.cookies['x-turbo-stream-id']
) {
return {
data: payload.template,
} as MessageEvent;
}
return;
case 'with-permissions':
// TODO: Implement permissions predicate
return {
data: payload.template,
} as MessageEvent;
default:
// This should never happen
return {} as never;
}
},
),
);
I implemented a cookie-based solution to only broadcast updates to the appropriate clients. This means that any client that connects the turbo stream endpoint gets assigned a unique identifier. We need this even in applications without log in so we can still send streams to the client that made the request of our other controllers. For example an ecommerce application would have guest users that add items to cart and we would want to update their cart at the top of the page.
Here's some examples of calling the send event from a NestJS controller where we broadcast to all clients and just the requesting client or "self" respectively:
this.appService.sendTurboStreamEvent(req, {
eventName: 'turbo-stream.event',
template: 'turbo-streams/update-message',
broadcastTo: 'all',
data: { message: newMessage },
});
this.appService.sendTurboStreamEvent(req, {
eventName: 'turbo-stream.event',
template: 'turbo-streams/create-message',
broadcastTo: 'self',
data: {
message: newMessage,
messagesCount,
},
});
That being said, I would be cautious using this method on anything production worthy. I put this together rather quickly and frameworks like Ruby on Rails, Laravel and Phoenix have been battle tested with this paradigm.
So while possible with NestJS, you'd likely need to implement additional security yourself before using Hotwire streams in a real app. While the Turbo docs cover the client side setup, you are left to dig into the Ruby on Rails setup if you want to uncover how this is implemented in the server.
Paradigm Shift from Declarative Frameworks
Coming from React, I noticed Hotwire requires more imperative DOM updates. Instead of just mutating state and letting the UI update, you explicitly target DOM elements to update.
This took some mental adjustment coming from React, however I can see the appeal as your updates are clean and straightforward to see based on action and targets set on the streams.
<turbo-stream action="append" targets="#messages ul">
<template>
...
</template>
</turbo-stream>
<turbo-stream action="update" target="messages-count">
<template>
...
</template>
</turbo-stream>
I also found going back to handlebars as a template language to be a bit lackluster. I do prefer JSX / TSX as a template language and feel if I were to take this further I would want to have a TSX to HTML implementation instead.
Performance and Scalability Unknowns
I'm curious how the server sent events would perform at scale compared to modern SPAs. While server sent events are more efficient than WebSockets, it's hard to know how well this would handle hundreds or thousands of connections.
I tend to not worry too much about scaling prematurely, however, you do want to have some idea of what scale could look like in terms of memory use and cost over time. In this case I'm not doing any benchmarking, but it would be something I would want to test before rolling out in production.
My gut feeling is that if you really love this paradigm there is one language and framework that stands out as designed precisely for this type of use case. Phoenix LiveView leveraging Elixir and running on top of BEAM, the ER Lang virtual machine, is the perfect tool for this job. I could also see GoLang being a nice contender for handling all of the connections, but of course you would have to build up that implementation yourself first.
Better Options for Production Apps
For me, NestJS with Hotwire seems like a fun experiment but not ideal for serious production apps yet. Modern meta-frameworks like NextJS, Nuxt, SvelteKit and others offer a better developer experience and confidence in scalability for the JavaScript ecosystem.
However, for developers using other languages, Hotwire is an incredible way to write more code in your favorite language and avoid the cognitive overhead of toggling between the frontend and backend in two different languages. If your language of choice doesn't have it's own HTML-over-the-wire style framework like Elixir, Ruby, and PHP--you should give Turbo a look for implementing this paradigm.
That being said, if you love this paradigm and have to learn a new language anyway to take advantage of this I feel Phoenix LiveView is probably the best place to put your efforts. It handles streaming and security out of the box and leverages Elixir/Erlang and the BEAM virtual machine for rock-solid scalability.
Final Takeaways
- Hotwire with NestJS works well but the developer experience is likely not going to push you away from the popular meta frameworks like NextJS, Nuxt, or SvelteKit.
- The paradigm shift from declarative frameworks like React takes some adjustment.
- For production use, I definitely recommend using it with Ruby on Rails since the community has already handled most of the backend concerns.
- You definitely don't need to implement a client side bundler if you don't want to anymore.
- Phoenix LiveView feels like the right tool for this job if you had to learn something new.
Let me know if you have any other questions! I'm happy to discuss more.
Top comments (0)