Full Stack With HTMX And Deno

Watch the video on YouTube

For a bit of context, I’ve been a software developer for almost 15 years now, with a background in Java based languages on the backend, and with experience mostly in React and Angular on the frontend. During this time I attempted and failed to launch more startup ideas than I’d like to admit. [I’m just an old man thinking about his mistakes] If I learned anything from this experience is that you need to iterate quickly, and get your product in front of customers sooner rather than later.

This is why it is so important to have a good understanding of new platforms like Deno and libraries like HTMX. They come packed with A LOT of features which will do the heavy lifting for you, and you can focus on building an MVP in a matter of days and then validate it with potential customers.

Backend

Before getting into the nitty-gritty, let’s quickly review the project architecture. The Deno platform will do all the heavy lifting on the backend. It will listen to incoming HTTP requests via Oak, will render HTML on the server via a lightweight templating engine, and will persist data via its KV native storage solution. Once the rendered HTML reaches the frontend, HTMX is loaded and used to build powerful UIs in a straightforward manner. Let’s install Deno, initialize a project and see how easy it is to build performant powerful backend services using this platform.

curl -fsSL https://deno.land/x/install/install.sh | sh

deno --version
deno 1.37.0 (release, x86_64-apple-darwin)
v8 11.8.172.3
typescript 5.2.2

deno init
Project initialized

Deno uses URLs for dependency management, and we can centralize the libraries and tools we’ll use in a dependency file.

//src/deps.ts

export {
  Application,
  Router,
  Context,
  send,
} from "https://deno.land/x/oak@v10.5.1/mod.ts";

export {
  viewEngine,
  etaEngine,
  oakAdapter,
} from "https://deno.land/x/view_engine@v10.5.1c/mod.ts";

Then, in the main.ts file, I am creating a new Oak application, registering a view engine, a router, and then finally start our app on the 8000 port.

//src/main.ts

import { Application, viewEngine, etaEngine, oakAdapter } from "./deps.ts";
import router from "./router.ts";

const app = new Application();

app.use(
  viewEngine(oakAdapter, etaEngine, {
    viewRoot: "./views",
  }),
);

app.use(router.routes());

await app.listen({ port: 8000 });

We are building a small micro blogging platform, I know.. very original, so in the router.ts file I’m defining a couple of post related endpoints with their associated handlers.

//src/router.ts

export default new Router()
  .get("/", (ctx) => ctx.render("index.html"))
  .get("/search", searchPostsHandler)
  .get("/posts", getPostHandler)
  .get("/posts/form/:id?", postFormHandler)

  .post("/posts", createPostHandler)
  .delete("/posts/:id", deletePostHandler)

  .get("/main.css", cssHandler)
  .get("/hero.png", imgHandler);

Then, in the handlers we are performing the usual Create, Read, Update and Delete operations you should be familiar with.

When a create request is received, we can retrieve the passed in data from the request body, and then store the information in the database.

//src/router.ts

export default new Router() {...}

async function createPostHandler(ctx: Context) {
  const body = await ctx.request.body().value;
  const title = body.get("title");
  const content = body.get("content");

  await createPost({title, content});

  ctx.render("posts.html", {
    posts: await getPosts()
   });
}

We’ll get back to KV in a second. Following the same approach, we can delete an entry from the database based on a unique id passed in as a path variable, or search in our list of entries based on a search key passed in as a request parameter.

//src/router.ts
export default new Router() {...}

async function createPostHandler(ctx: Context) {...}

async function deletePostHandler(ctx: Context) {
  const { id } = ctx.params;
  await deletePost(id);
  ctx.render("posts.html", {
    posts: await getPosts(),
  });
}

async function searchPostsHandler(ctx: Context) {
  const key = ctx.request.url.searchParams.get("key");
  ctx.render("posts.html", {
    posts: await searchPosts(key ?? "")
  })
}

Note that all these handlers are returning HTML back to the client using the context render method. This will be relevant in the second part of the article, when we’ll discuss the frontend implementation.

The handlers are also relying on database service methods, so, in a new file, let’s open a Deno KV connection, define a type for our entity, and then create helper methods for the database operations we’ll need.

//src/services.ts

const kv = await Deno.openKv();

type Post = {
  id: string;
  title: string;
  content: string;
};

export async function createPost(post: Partial<Post>) {
  const id = crypto.randomUUID();
  kv.set(["posts", id], { ...post, id });
}

export async function getPost(id: string) {
  return (await kv.get(["posts", id])).value;
}

export async function updatePost(data: Partial<Post>) {
  const post = await getPost(data.id!);
  post.content = data.content ?? "";
  kv.set(["posts", data.id!], { ...post });
}

export async function deletePost(id: string) {
  kv.delete(["posts", id]);
}

Deno KV

It should be more than obvious how easy it is to create, read, update, or delete data using Deno KV.

If you are not familiar with KV, just know that this is a key value, globally distributed persistence solution built on top of Apple’s FoundationDB. It is natively integrated into the Deno platform, so no need to install other dependencies or libraries, and, for convenience reasons, it uses SQLite when running locally for a seamless dev experience.

Frontend

Let’s now move to the frontend, where things get even easier thanks to straight forward developer experience HTMX is offering. Note that all we have to do is add in a script in the HTML Head area. No Vite plugin, no compilation step or build process.

<!-- src/views/index.html -->

<head>
  <script src="https://unpkg.com/htmx.org@1.9.6"></script>
</head>
<body>
  <div class="app">
    <aside>
      <button hx-get="/posts/form/" hx-target="#main-view">Write</button>
    </aside>

    <main>
      <input
        type="search"
        placeholder="Search..."
        name="key"
        hx-get="/search"
        hx-trigger="keyup changed delay: 500ms"
        hx-target="#main-view"
      />
      <div hx-get="/posts" hx-trigger="load" hx-target="#main-view">
        <div id="main-view"></div>
      </div>
    </main>
  </div>
</body>

Inside the views folder, I defined the templates Deno will use to render the HTML on the server. Once the response reaches the browser, HTMX is downloaded and executed. As a result, any plain old DOM element can become the source of an async HTTP request, and a trigger for other DOM updates.

This is how for instance an input field can easily trigger a search request to the server, and then update the main-view element with the received response.

Moving to the posts.html template which is rendered when the user wants to see all the written articles, or to search them, we can also see how plain HTML buttons can trigger delete requests to the server, and how actions can be guarded by a confirmation modal.

<!-- src /views/posts.html -->

<ul class="posts">
  <% it.posts.forEach(post => { %>
  <li>
    <header>
      <h2><%= post.title %></h2>
      <nav>
        <button hx-get="/posts/form/<%= post.id %>"></button>
        <button
          hx-delete="/posts/<%= post.id %>"
          hx-confirm="Are you sure?"
        ></button>
      </nav>
    </header>
    <p><%= post.content %></p>
  </li>
  <% }) %>
</ul>

Now that we have the app running, by the way, this is the Github code, it’s time to publish this app at a public URL. If you have any experience with deployments, you know this is a headache. Databases have to be provisioned, environments need to be configured, and load balancers have to be put in place to provide scalability and stability. Well.. with Deno Deploy we don’t have to worry about any of these.

Deno Deploy

Simply log in into Deploy, link your github account, click on the new project button and link your project repository. Click on create and deploy and that’s it. Deno will take care of everything else. After a few seconds, you’ll get a production deployment hosted on a public domain. On top of that, you have easy access to logs, analytics and full control over your KV instance.

I think we can all agree that this is pretty impressive, but I can see how this stack might not be your cup of tea. After all, HTMX offers a different developer experience compared to some other established UI frameworks. I’m focusing a lot on efficiency on this channel, and here are a couple of alternative tech stacks (KISS & HTMX + Go) which are still heavily focused on simplicity and efficiency.

Until next time, thank you for reading!