Web front-end architecture with minimal client-side JavaScript, using Bun, Hono and htmx
As a wise man once said, every country eats some kind of squishyfood-filled bread, and while the mascot at bun.sh is probably more related to a steamed bao bun, today we are going to explore the way of putting it through the flame of Hono.dev. The food metaphor stops right here but we are also going to make use of htmx.org.
I find the bun-hono-htmx stack highly relevant in today’s web ecosystem. It enables the creation of dynamic web applications while keeping client-side complexity relatively low, focusing on serving only HTML. This approach offers several advantages such as faster rendering speeds and consistent SEO & SMO performance.
Htmx specifically allows us to build products that deliver a seamless and modern user experience by reloading only relevant parts of a page. Additionally, the developer experience is enhanced with native support for TypeScript and JSX by bun and hono, making development satisfying and efficient.
Enough context, let’s dive in
Boilerplate
First step : Install Bun. Just follow the instructions on their site. It’s quick and easy!
There is even a tutorial for integrating with Hono. We can use the bun CLI to start a project with a template. We are prompted to choose the template, for me it will be bun, no surprise
The architecture and complexity of the files are basic at the beginning, but all the essential elements are already there.
As I said, this template is natively supporting typescript, so we have this tsconfig.json file already there
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"module": "esnext",
"moduleResolution": "node",
"target": "esnext",
"esModuleInterop": true,
}
}
Let’s confirm it works
Try running the projet with the bun run dev command, you should have this success message
bun run dev
$ bun run --hot src/index.tsx
Started server http://localhost:3000
Everything went well, we can see the first content served by our local server.
HTML responses
As with any web server frameworks, it is easy to serve html with Hono via the html method of the context object. Template literals can already be useful to create simple HTML templates.
Hono also provides a html helper, which you could use as a return value of a function, and create some kind of a component even without the use of JSX per se.
Anyway, as Hono can handle JSX syntax, this type of function can be interpreted as JSX right in route handlers ! Just rename your file from index.js to index.jsx (or .tsx if you are using typescript) and you can mix those syntaxes in the same template.
This is already giving many options to compose elements and create complex interfaces, but as their size increase we could fear that template strings will not be as easy to work with than actual HTML or JSX files. Even more, writing semantically correct JSX could be an advantage if those files were to be reused in another project.
Let’s remain ordinary
Nothing unusual with this step, we will write a simple Card component, displaying data received as props.
type CardProps = {
name: string;
picture: string;
abilities: string[];
}
export default function Card ({ name, picture, abilities }: CardProps) {
return (
<div className="Card">
<h2 class="Card__name">{name}</h2>
<img class="Card__picture" src={picture} alt={`profile picture of ${name}`} />
<ul class="Card__abilities">
{
abilities.map(ability => (
<li class="Card__ability">{ability}</li>
))
}
</ul>
</div>
)
}
Now we have a very ordinary component, usually encountered in React projects, but easily understandable by any developer who has worked their way through front-end templating. JSX syntax allows us to write conditions, loop and basically use all of javascript tools to handle complexity inside a highly reusable component. I don’t need to promote and explain why this format is so widely adopted in today’s industry, let’s put our attention on how to use it here.
Use what we learnt
Okay, now that we have all this info, let’s bring it together. We will first create a route /Card responding a card with data fetched from a parameter.
app.get('/card', c => {
const { profileID } = c.req.query()
const profile = getProfileFromID(profileID)
return c.html(<Card name={profile.name} abilities={profile.abilities} picture={profile.picture} />)
})
Here we are retrieving an ID from a query parameter, and with it we can make a call for the full profile data. Then we can pass this data to the Card component we just created using the props syntax of JSX.
Then again, nothing fancy here, the getProfileFromID function could be a network call, but since we already are working server side, we could imagine it to end up in a database query or any other way to fetch data securely.
We can try to reach http://localhost:3000/card?profileID=2 to confirm it works, we should be seeing our Card component rendered as html.
Composability
I mentioned composability of templates earlier, and obviously it is a common pattern to create complex interfaces. This is also achievable within our project. For instance we could create a layout, reusable on every pages of our web application.
import { Child } from "hono/jsx" // Hono exposes a type for any object nested in a template
export default function ({ children }: { children: Child}) {
return (
<html>
<head>
<title>Let's bake some buns !</title>
<link rel="stylesheet" href="/static/global.css" />
<script src="https://unpkg.com/htmx.org@2.0.1"></script>
</head>
<body>
{children}
</body>
</html>
)
}
Very useful to factorize a head element, or a footer from different pages. Hono makes it really straightforward to set up in our index file.
import DefaultLayout from './layout/default'
import { jsxRenderer } from 'hono/jsx-renderer'
app.get('/app/*', jsxRenderer(({children}) =>
<DefaultLayout>
{ children }
</DefaultLayout>)
)
htmx enters the chat
Time to start working with our last guest, htmx. Hello htmx, could you introduce yourself ?
htmx gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext
htmx is small (~14k min.gz’d), dependency-free, extendable & has reduced code base sizes by 67% when compared with react
This is a nice fit with our architecture here : send minimum javascript to the client, and use templates super simple to maintain.
You may have seen that I already made good use of the layout component to import htmx on every page via their CDN. You could imagine making this import conditional of course, in the case of fully static pages.
That’s it for configuring htmx by the way. You can already try it on your project.
We have layout and components, we just miss pages
Ok this project is starting to be very well equipped, it just needs to do something now. Let’s build a home page, in the same way that we wrote our layout and component.
import ProfileFinder from "../components/ProfileFinder"
import profiles from "../data/profiles"
export default function Home () {
return (
<div className="Home">
<h1>Welcome to the home page</h1>
<ProfileFinder profiles={profiles} />
</div>
)
}
We introduce here an another component ProfileFinder, in which we inject some data. here retrieved from a local file profiles, but we could imagine making a call to any data source.
app.get('/app', c => {
return c.html(<Home />)
})
Once again, very easily adapted in the index file. At this point, we have a file structure like this :
bakery-stack
├── src
│ ├── index.tsx
│ ├── components
│ │ ├── Card.tsx
│ │ └── ProfileFinder.tsx
│ ├── pages
│ │ ├── Home.tsx
│ ├── layouts
│ │ ├── default.tsx
├── package.json
└── tsconfig.json
A server side dynamic UI
This is what the profileFinder component will look like.
import { TProfile } from "../data/profiles"
export default function ProfileFinder ({profiles}: {profiles: TProfile[]}) {
return (
<>
<div class="ProfileFinder">
<h2>Select a profile</h2>
<label>profile ID : </label>
<select name="profileID"
hx-get="/card" /* route */
hx-target="#profiles" /* swapped element */
hx-trigger="revealed, change" /* triggering events */>
{profiles.map((p, i) => <option value={i}>{i}</option>)}
</select>
</div>
<div id="profiles"></div>
</>
)
}
This a classic way of using htmx, just to display one of the possibilities given by the library. To describe it briefly, we have a select element, made richer with three htmx attributes :
- hx-get defines which route is going to be called (via the get method, but we could also use hx-post)
- hx-target defines the selector of the element which is going to be swapped with the request response body
- hx-trigger defines the events that are triggering this work
This means when we will change the selected option, a request is going to be made to the /card route we defined earlier, and the html served by this route is going to be swapped with the #profiles element in the ProfileFinder template. This is the only change to the DOM made by the user’s action.
There are many other options available to create dynamic UIs, with locally updated content, without the need to refresh the whole page, like the possibility of having a loader displayed when waiting for the request’s result.
And…voilà ?
Yes that’s it ! Of course this is a basic example, and we would need to add other layers to launch it to production, but we have virtually everything set up to start building a complex website or application.
In the front-end ecosystem, we are lucky enough to work with very advanced javascript frameworks and libraries that enable a wide range of functionalities. Yet, there are times when relying less on JS and client-side operations can be more effective. Sometimes, the better solution is sticking to the “old-fashioned” approach of a server sending HTML to the client.
There are many scenarios where this approach creates better results, in terms of performances, security, or simplicity. And actually many other frameworks like Astro or Qwik are starting to gain popularity based on similar concepts
Keeping the complexity on the server may also address environmental and accessibility concerns, as we can make our applications available to older smartphones with the same level of execution quality without requiring more computing power from personal devices.
As a software engineer, I found working with this stack particularly enjoyable due to minimal configuration and a modern, easy-to-understand experience. However, it’s worth noting that all three libraries are still relatively new and lack the maturity and community support of more established technologies.
Also, I decided to use Bun and Hono as a way to explore new techs, but I am sure you could achieve the same with Node and express.
The sources for this example are available on this github repository, and you can reach a working demo right here.
Thank you for reading this, I hope it helps you or inspires ideas !