https://www.lorenstew.art/blog/eta-htmx-lit-stack/
* Home
* Tags
* Contact
RSS
* Home
* Tags
* Contact
[ ]
Loren Stewart
Loren Stewart
Software Developer
Recent Posts
* Architecting with Constraints: A Pragmatic Guide Jul 2, 2025
* Building a Lightweight Reactive State Manager with JavaScript
Proxies Jul 1, 2025
* Beyond REST: Adding Real-Time Interactivity with NestJS
WebSockets Jun 15, 2025
* Don't Just Talk, Do: Giving Your LLM Tools to Interact with the
Real World Jun 10, 2025
* How to Generate Dynamic PDFs in NestJS with a Headless Browser
May 20, 2025
Tags
* Frontend
* Architecture
* JavaScript
* React
* Astro
* Lit
* NextJS
* State Management
* Proxy
* NestJS
* TypeScript
* WebSockets
* Realtime
* Backend
* AI
* LLM
* MCP
* PDF
* Playwright
* HTMX
* Web Components
* Events
* Security
* Authentication
Our Fullstack Architecture: Eta, HTMX, and Lit
May 3, 2025
HTMX Frontend Architecture JavaScript Web Components Lit
Why choose between a fast MPA and a rich SPA? Our fullstack approach
with Eta, HTMX, and Lit delivers the best of both worlds.
---------------------------------------------------------------------
Our Fullstack Architecture: Eta, HTMX, and Lit
In modern web development, we're often presented with a false choice:
either build a heavy, complex Single-Page Application (SPA) with a
framework like React or Vue, or stick with a "traditional,"
server-rendered Multi-Page Application (MPA) that can feel clunky and
slow. The SPA world gives you rich interactivity but often at the
cost of a massive JavaScript bundle, architectural complexity, and
slower initial load times. The MPA world is fast and simple but can
lack the smooth, dynamic user experience we've come to expect.
But what if you didn't have to choose? What if you could combine the
speed and simplicity of server-side rendering with rich, targeted
interactivity, using only what you need, when you need it?
We found a way to get the best of both worlds by composing three
distinct, focused technologies into a single, cohesive stack: Eta for
server-side templates, HTMX for the majority of user interactions,
and Lit Web Components for powerful, stateful client-side logic when
it's truly necessary.
A Note on Performance and Bundle Size
Before we dive into the "how," let's talk about the "why." A primary
motivation for this architecture was to dramatically reduce the
amount of JavaScript shipped to the client, leading to faster page
loads and a much better user experience, especially on mobile devices
or slower networks.
By rendering HTML on the server and using hypermedia-driven
interactions, we keep our client-side JavaScript to a minimum. It's
only used for what's absolutely necessary: targeted, rich
interactivity. The results speak for themselves. Here's a comparison
of the home page's initial JavaScript bundle size for this
application versus several other React-based Single-Page Applications
where I work:
---------------------------------------------------------------------
Home Page JS Size
This App 151KB
Plain React App 1 635KB
Plain React App 2 968KB
Plain React App 3 973KB
NextJS App 1 2.6MB
NextJS App 2 2.6MB
---------------------------------------------------------------------
This isn't a magic trick; it's a direct result of our architectural
choices. By prioritizing server-side rendering and minimizing our
reliance on client-side frameworks, we've built an application that
is not only fast but also more resilient and accessible.
The Foundation: Eta for Fast Server-Side Templating
The first principle of our architecture is to do as much work on the
server as possible. Fast initial page loads are critical, and the
best way to achieve that is to send fully-formed HTML to the browser.
This means less work for the client, a faster Time to First
Contentful Paint, and a much smaller JavaScript footprint.
For this, we chose Eta. It's a lightweight, fast, and simple embedded
JavaScript templating engine. It doesn't try to do too much; its main
job is to render our data into HTML on the server before we send it
down the wire. It's performant, easy to learn, and integrates
seamlessly into our NestJS backend.
A typical Eta template is clean and easy to read:
<% // views/partials/user-greeting.eta %>
Hello, <%= it.userName %>!
You have <%= it.messageCount %> new messages.
By using Eta, we ensure our application's foundation is a simple,
fast, server-rendered MPA. This is our baseline, and it's a solid
one.
The Workhorse: HTMX for 90% of Interactions
A static MPA isn't enough for a modern application. Users expect to
filter lists, submit forms, and see content update without a full
page refresh. This is where HTMX comes in, and it handles the vast
majority of our dynamic interactions.
HTMX allows us to add dynamic behavior directly to our
server-rendered HTML. Instead of writing JavaScript to handle a
click, make a fetch request, and then manually update the DOM, we can
add a few simple attributes to an element.
Consider a button that archives a listing in a table:
When this button is clicked, HTMX shows a confirmation dialog. If the
user confirms, HTMX sends a POST request to /api/listings/123/
archive. The server processes the request and returns a small snippet
of updated HTML--perhaps a "Listing archived" message or an empty
. HTMX then swaps this new HTML into the element with the ID
listing-row-123, replacing the original row.
This simple, powerful pattern covers about 90% of our client-side
needs. It's declarative, easy to understand, and keeps our
application logic centralized on the server where it belongs. We're
just enhancing our server-rendered HTML, not replacing it with a
heavy client-side framework.
The Soloist: Lit for Islands of Interactivity
For the remaining 10% where we need targeted, powerful client-side
logic, we turn to Lit to build standard, performant Web Components.
Lit allows us to create encapsulated, reusable components with their
own internal state and logic. They are "islands of interactivity" in
our sea of server-rendered HTML. Our pagination component is a
perfect example.
First, we embed the component in our Eta template, passing the
initial state from the server:
<% // views/homePage/homePageTableAndPagination.eta %> ...
<%~ include("./table.eta", it) %>
...
The component is built with Lit. It takes the total
number of items and the current page and internally calculates how
many page links to show. Here's a simplified look at its render
method:
// web-components/src/components/ev-pagination.ts
// ... Lit component setup ...
render() {
return html`
${this.items.map((pageNumber) => html`
`)}
`;
}
The real magic of this integration is the hx-vals attribute. By
prefixing the value with js:, we're telling HTMX to execute a snippet
of JavaScript to generate the request payload. In this case, hx-vals=
'js:{...createPayload({page: ${pageNumber}})}' calls a global
createPayload helper function. This function is responsible for
gathering any other necessary state from the page--like current sort
fields or search terms--and packaging it all up into a single JSON
object for the POST request. This is an incredibly powerful feature
that keeps our templates clean while allowing for complex, dynamic
payloads.
This component does two things beautifully:
1. It handles its own internal UI state. When you click "Next" or
"Previous", Lit's @click handlers update the component's internal
page property and re-render the list of page numbers--all on the
client, with no server request.
2. It integrates perfectly with HTMX. Each individual page number
button has hx-* attributes. As we've seen, this tells HTMX to
fetch the new page content from the server and intelligently swap
it into the #results div.
This is the "island" model in action. The component is a
self-contained, stateful unit, but it plays by the hypermedia rules
of the rest of the application.
The Polish: Seamless Transitions with the View Transitions API
A fast application is great, but a fast application that feels fast
is even better. This is where the browser's native View Transitions
API comes in. It allows us to create smooth, animated transitions
between different DOM states, which can make navigation and content
swaps feel incredibly fluid, masking network latency and eliminating
jarring content flashes.
HTMX offers first-class support for this API, and enabling it
couldn't be simpler. All it takes is a single line of configuration:
// src/client-scripts/global-helpers.ts
htmx.config.globalViewTransitions = true;
With this enabled, HTMX automatically wraps its DOM updates in a
document.startViewTransition. The browser then intelligently handles
the crossfade animation between the old and new content.
But what if you don't want a transition for every single interaction?
We've accounted for that too. We added a simple event listener that
checks for a data-no-transition attribute on the element that
triggers an HTMX request. If it finds one, it temporarily disables
the transitions.
// src/client-scripts/global-helpers.ts
appEvents.on("htmx:beforeRequest", (data) => {
const targetElement = data.element;
if (Object.hasOwn(targetElement.dataset, "noTransition")) {
document.documentElement.classList.add("no-view-transition");
}
});
appEvents.on("htmx:afterRequest", () => {
// Always remove the class after the request finishes
document.documentElement.classList.remove("no-view-transition");
});
And in our CSS, we simply disable the transition when that class is
present:
::view-transition-group(root) {
animation-duration: 0.3s;
}
.no-view-transition::view-transition-group(root) {
animation-duration: 0s;
}
This gives us the best of both worlds: smooth, animated transitions
by default, with the option to opt-out for small, rapid updates where
an animation would be distracting. It's a small touch that has a huge
impact on the overall user experience.
How It All Works Together
The magic is in how these three technologies work together, each
playing its part without getting in the way of the others:
1. Eta renders the initial page on the server, including the
of data and the web component. This provides a
fast, content-rich, and fully functional baseline.
2. Lit powers the component, which manages its own
complex UI state (like which page numbers to display) entirely on
the client-side for a snappy user experience.
3. HTMX provides the bridge back to the server. When a page number
is clicked inside the Lit component, HTMX sends the request, gets
a server-rendered HTML partial in response, and updates the main
content area.
This "best-of-all-worlds" approach gives us the performance and
simplicity of a server-rendered application with the smooth, modern
user experience of a SPA, but only where it's truly needed. It's a
pragmatic, productive stack that avoids the all-or-nothing trap of
monolithic frameworks, allowing us to build faster, simpler, and more
maintainable web applications.
Tags:
HTMXFrontendArchitectureJavaScriptWeb ComponentsLit
- Back to Blog
(c) 2025 Loren Stewart. All rights reserved.
Powered by Astro