ZenGPT: a simple ChapGPT alternative frontend

Published on
Last updated

I’ve been playing around with a home-made, super simple ChatGPT UI clone, mainly with the excuse to try out htmx and Alpine.js

It’s a fun little project that I’ve been working on for a few days.

I’ve been programming it on an iPad Pro (as my main device), and it’s been a fun experience.

In this post I want to get deeper into the technical details of the project, and share some of the things I’ve learned while working on it.


node.js server and bare functions as views

The other day I wrote about functions as views, and how I’ve been using them in this project.

Namely, I’m using the native http node.js module, a simple home made router with if statements and a few functions that return HTML strings.

The functions are called with optional additional data and they return a string that is sent back to the client.


if (req.url === '/') {
  res.setHeader('Content-Type', 'text/html')
  return res.end(mainView(messages, listing()))

Integrating with OpenAI’s API

The Completions API is pretty straightforward too:

You can use the chat.completions.create method to send the conversation to the API, and get back a text completion (llm response/message).

const completion = await ai.chat.completions.create({
  model: 'gpt-3.5-turbo',
  messages: messages
              .concat([{ role: 'user', content: newUserMessage }]),

let llmText = completion.choices[0].message.content.trim()


As mentioned before, the main reason I started this project was to try out htmx (and Alpine.js)

The coolest thing I refreshed during this excursus was the concept of rethinking what we consider RESTful APIs (spoiler: they are actually HTTP JSON RPC APIs), HATEOAS, hypertext, and much more honestly. The htmx website is a goldmine of resources.

In short: our websites and “RESTful” APIs should be way more discoverable (for humans, not machines) and self-contained.

By making use of existing powerful technology like HTML and HTTP, with sprinkles of JavaScript, to make the web more accessible, lightweight, more reliable and easier to use.

But let’s got back to htmx.

build modern user interfaces with the simplicity and power of hypertext

The emphasis here is leveraging the power of HTML.

E.g. in this small project, the client is loaded with a simple HTML page, preloaded with messages from the server.

The rest (sorry for the poor choice of words) is done by the client, that makes requests to load small snippets of HTML, and updates the DOM with the response.

This is an oversimplification, but it’s the gist of it.

By using a declarative approach on the HTML, you can get a quite robust and powerful UI, with very little code.

The main focus of ZenGPT is the UI, this is the input and conversation part:

<div id="messages" hx-swap="scroll:bottom">
  hx-swap="beforeend scroll:bottom"
  hx-on:htmx:after-request="this.disabled=false;setTimeout(() => this.focus(), 20)"
  x-on:keyup.enter="setTimeout(() => {message = '';pristineChat = false}, 10)"
  class="my-message" autofocus type="text" placeholder="your message">
<div style="position:fixed;bottom:3em;right:2em;" class="htmx-indicator" id="loading-message">
  <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="64" height="64" viewBox="0 0 24 24">
    <path fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" d="M12 2 L12 6 M12 18 L12 22 M4.93 4.93 L7.76 7.76 M16.24 16.24 L19.07 19.07 M2 12 L6 12 M18 12 L22 12"></path>

This is so-called “no-javascript”, where the JS is simply hidden (remember you need to include a <script src="//unpkg.com/htmx.org">). I think it’s a refreshing approach.


In ZenGPT, Alpine.js is used to manage the state of the UI, and to make client-side only UI changes and updates.

It is used to add interactivity to the UI.

E.g. it handles the display state of the action buttons in the header

<div style="display:flex">
  <div style="flex:1";><h1>zengpt</h1></div>
  <div x-show="!pristineChat" style="flex:1;";>
    <button style="display:block;padding:1rem;font-size:1.5rem;" hx-delete="/chat" hx-target="#messages" x-on:click="$refs.message.focus();messageDisabled=false;pristineChat=true">
      new chat
  <div x-show="!pristineChat" style="flex:1;";>
    <button style="display:block;padding:1rem;font-size:1.5rem;" hx-post="/chats" hx-target="#messages" x-on:click="$refs.message.value = '';messageDisabled=false;pristineChat=true">
      save chat
  <div x-show="viewingPreviousChat" style="flex:1;";>
    <button style="display:block;padding:1rem;font-size:1.5rem;" hx-get="/chat" hx-target="#messages" x-on:click="$refs.message.value = '';messageDisabled=false;">
      go back

open source

You can find the project on github.com/christian-fei/zengpt

Server-sent events (SSE)

Update 2023-10-22: I’ve added support for Server-sent events (SSE) to the project!

This was super a interesting endeavour, and I’ve learned a lot about htmx extensions and SSE.

Here, have a slice of pizza 🍕