Our Fullstack Architecture: Eta, HTMX, and 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 %>
<h2>Hello, <%= it.userName %>!</h2>
<p>You have <%= it.messageCount %> new messages.</p>
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:
<!-- An HTMX-powered button -->
<button
hx-post="/api/listings/123/archive"
hx-target="#listing-row-123"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to archive this listing?"
>
Archive
</button>
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 <tr>
. 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 %> ...
<div id="results"><%~ include("./table.eta", it) %></div>
...
<div id="pagination-container">
<ev-pagination
pageType="home"
total="<%= it.totalListings %>"
limit="<%= it.limit %>"
page="<%= it.page %>"
>
</ev-pagination>
</div>
The <ev-pagination>
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`
<!-- "Previous" and "Next" buttons with local click handlers -->
<button @click=${this.onBefore}>Previous</button>
<!-- The list of page numbers -->
${this.items.map((pageNumber) => html`
<button
class=${pageNumber === this.page ? "active" : ""}
// This is where Lit and HTMX meet!
hx-post="/api/v1/paginated-query-endpoint"
hx-target="#results"
hx-swap="outerHTML"
hx-vals='js:{...createPayload({page: ${pageNumber}})}'
>
${pageNumber}
</button>
`)}
<button @click=${this.onNext}>Next</button>
`;
}
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:
- It handles its own internal UI state. When you click “Next” or “Previous”, Lit’s
@click
handlers update the component’s internalpage
property and re-render the list of page numbers—all on the client, with no server request. - 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:
- Eta renders the initial page on the server, including the
<table>
of data and the<ev-pagination>
web component. This provides a fast, content-rich, and fully functional baseline. - Lit powers the
<ev-pagination>
component, which manages its own complex UI state (like which page numbers to display) entirely on the client-side for a snappy user experience. - 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.