Building Hypermedia Applications with elysiajs, react and htmx

Building Hypermedia Applications with elysiajs, react and htmx
In this series of posts, we will explore hypermedia APIs and use htmx to build a simple search application that queries characters from the Start Wars universe, using this free api - swapi.dev:
The source code for this post can be found here on branch phase-1, pull this gihub repo and follow along with these examples.
The stack includes the following:
- htmx
- elysiajs
- react
- bun
Server Side Rendering with JSX/React
This first post focuses on server side rendering. In order to render HTML server side, we need to use a templating engine, many exist, handlebars, pug and nunjucks are poplular examples. We will be using Typescript and I wanted to use typed templates so we will be using JSX/React as a template engine.
I have chosen to use Elysia, a web framework that runs on the bun runtime, Elysia has support for rendering JSX server side out of the box.
So before we begin make sure you have Bun installed on your machine
curl -fsSL https://bun.sh/install | bash
You can then navigate to the repo above, and install the dependencies and start the application,
bun install
npm run start
This will install two dependencies
- elysia
- @elysiajs/html
Open localhost:3000 with your browser to see the result.

Lets get JSX working as a template engine
In order to use React/JSX as a template engine, we need to bare a few things in mind, just like when building react applications client side; all files that use JSX need to have the file extension .jsx or in our case, we are using Typescript, so .tsx.
As we are using Typescript, the second thing to consider here is to set the following compiler options in our tsconfig file, jsx, jsxFactory and jsxFragmentFactory. This will allow us to to use the @elysiajs/html plugin to handle JSX.
More information here on the elysiajs html plugin html-plugin
tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "Html.createElement",
"jsxFragmentFactory": "Html.Fragment",
....
}
}
Lets build
Creating a server using elysia is very similar to other javascript server side frameworks like express. We instantiate a new app, and attach any plugins or middleware. We can add our routes, and listen on a port. The server below is super simple, and uses the aforementioned @elysiajs/html plugin to handle JSX, and we attach a single route, Index, that returns our HTML page, via a JSX template.
src/index.tsx
import { Elysia, t } from 'elysia'
import { html } from '@elysiajs/html'
import { Index } from './api'
export const app = new Elysia()
.use(html())
.get("/", Index)
.listen(3000)
export type App = typeof app
Our Index page, performs a search and renders various elements:
Layout- renders,html,head, andbodytags.Home- renders a search form, with aninputtag calledsearchCharacterList- renders the search results
The starwars search service, takes the input from the elysia query object, and queries the starwars api. The data returned is passed to the CharacterList which renders the people returned.
An important note, the react elements are wrapped via the html function, from the plugin @elysiajs/html.
src/api/index.tsx
import { Layout } from '../components/layout'
import { Home } from '../components/home'
import { CharacterList } from '../components/characterList'
import { search } from '../services/starwars'
export type IndexType = {
html: (children: JSX.Element) => JSX.Element
query: string
}
export const Index = async ({ html, query }: IndexType): Promise<JSX.Element> => {
const title = 'These are not the droids you are looking for'
const q = query?.search?.toString() || ''
const data = await search(q)
return html(
<Layout title={title}>
<Home title={title}>
<CharacterList count={data?.count} results={data?.results} query={q} />
</Home>
</Layout>
)
}
The Layout component, is a simple HTML page, we also import tailwindcss to add some styles to our page. Important note, this component has children as argument, so we need to use the html function PropsWithChildren to handle this correctly. We also Handle xss issues, with children as 'safe'.
src/components/layout.tsx
export type LayoutType = {
title?: string
children?: JSX.Element
}
export function Layout({ title, children }: Html.PropsWithChildren<LayoutType>): JSX.Element {
return (
<>
{'<!doctype html>'}
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<title safe>{title}</title>
</head>
<body>
<div class="m-8">
{children as 'safe'}
</div>
</body>
</html>
</>
);
}
The Home component renders a simple form, which posts to root / This form has a single input element, we also render the children, which is a CharacterList component, we again are using PropsWithChildren to handle jsx.
src/components/home.tsx
export type HomeType = {
title?: string
children?: JSX.Element
}
export function Home({ title, children }: Html.PropsWithChildren<HomeType>): JSX.Element {
return (
<div class="m-8">
<h1 class="text-8xl font-bold" safe>
{title}.
</h1>
<form action="/">
<input
class="mt-8 p-4 text-6xl font-bold text-blue-700 border-2 border-gray-100 rounded-md"
autofocus="true"
type="search"
name="search"
placeholder="Search..."
/>
</form>
<div id="results">{children}</div>
</div>
)
}
The CharacterList component takes the results returned from the starwars api as argument, for each result item renders a Character component.
src/components/characterList.tsx
import { StarWarsResponseType } from '../services/starwars'
import { Character, CharacterType } from './character'
export function CharacterList({ results, count, query }: StarWarsResponseType): JSX.Element {
const items = results?.map((d: CharacterType) => <Character {...d} />)
return (
<div class="mt-4">
<h1 class="mb-4"> Found <span safe>{count}</span> results for <span safe>{query}</span>.</h1>
<span>{items}</span>
</div>
)
}
The Character component simply renders a single character.
src/components/character.tsx
export type CharacterType = {
name?: string,
height?: string,
mass?: string,
hair_color?: string,
skin_color?: string,
eye_color?: string,
birth_year?: string,
gender?: string
homeworld?: string,
films?: string[],
species?: string[],
vehicles?: string[],
starships?: string[],
created?: string,
edited?: string,
url?: string,
}
export function Character({ name, height, mass, hair_color, skin_color, eye_color, birth_year, gender }: CharacterType): JSX.Element {
return (
<div class="mt-4">
<h1 class="mb-4 text-4xl font-bold" safe>{name}</h1>
<div>Height: {height}</div>
<div>Mass {mass}</div>
<div safe>Hair Color: {hair_color}</div>
<div safe>Skin Color: {skin_color}</div>
<div safe>Eye Color: {eye_color}</div>
<div safe>Birth Year: {birth_year}</div>
<div safe>Gender: {gender}</div>
</div>
)
}
The starwars service simply calls the api, accepting a query as argument, and returns results in json format.
src/services/starwars.ts
import { CharacterType } from "../components/character";
export type StarWarsResponseType = {
count: number;
results: CharacterType[]
query: string
}
export const search = async (query: string): Promise<StarWarsResponseType> => {
const response = await fetch(`https://swapi.dev/api/people/?search=${query}`)
return response.json()
}
Wrapping up
Thats all, next post we will add htmx to this application.