Implementing a comments feature for my blog with nbb, htmx, Serverless Framework, and DynamoDB

TLDR: here's the repo https://github.com/NickCellino/nbb-comments. Usage instructions are in the README. Scroll down to the end of this post to see the comments feature in action.

Recently, I've been trying to work on writing more (on this blog in particular). I thought it would be more fun and interactive if people could leave comments on my posts. I know, very novel.

I've also been trying to learn and get better at Clojure so I thought "why not build my own comments feature in Clojure, then write about it on my blog?". So that's what I did.

My Goals

  1. Build a comments feature that is easy to integrate into my website, which is hosted on Github pages and does not have a dedicated backend
  2. Allow users to leave comments easily (without making an account or requiring social login or anything like that)
  3. Implement some mechanism to prevent bots from sending in spam comments

I'm aware that without logins, normal users can easily spam me with excessive comments and lie about who they are. We'll see if that becomes a problem. But if we can't trust anonymous strangers on the internet, then who can we trust, really?

How it works

The comments service runs on an AWS Lambda function, using ClojureScript and nbb. nbb makes it possible to execute ClojureScript within a NodeJS environment and with some very simple wiring up, you can run this in a Lambda function. The Lambda function interacts with a database in DynamoDB in order to store and retrieve comments, grouped by their blog post ids.

Express & htmx

I used express in my Lambda to handle routing requests to the few different endpoints that I have setup:

  1. GET /comments-form?post-id=[post_id]: returns the HTML for the "add a comment" form
  2. GET /comments?post-id=[post_id]: returns the list of comments for the specified blog post, rendered as HTML
  3. POST /comments: saves a comment to DynamoDB and returns the newly saved comment, rendered as HTML
The code to set that up looks something like this:
(ns express
  (:require ["express$default" :as express]))

(defn create-app
  [{:keys [allowed-origin-url] :as config}]
  (let [app (express)]
    (.use app (.urlencoded express #js {:extended true}))
    (.use app (fn [_ res next]
                (doto res
                  (.set "Access-Control-Allow-Origin" allowed-origin-url)
                  (.set "Access-Control-Allow-Methods" "GET, POST")
                  (.set "Access-Control-Allow-Headers" "hx-trigger, hx-target, hx-request, hx-current-url"))
                (next)))

    (.get app "/comments" (get-comments-handler config))
    (.post app "/comments" (post-comment-handler config))
    (.get app "/comments-form" (get-comments-form-handler config))

    app))

I used htmx to make the frontend interactive and dynamic without having to write any sort of frontend DOM-manipulation JS. (I highly recommend you check out htmx if you've never heard of it. It's sort of an alternative to the React/Angular/Vue style SPA frameworks everyone is using these days for building dynamic frontend applications. Their essays are also really good reads from the few I've read so far.)

The way that works is basically: certain elements of the DOM are marked with hx-<attr> which give them certain dynamic properties. For example, in our HTML, where we want to load in the comments list, we add:

<div
  id="comments-list"
  hx-get="http://localhost:3000/comments?post-id=example-post-id"
  hx-swap="innerHTML"
  hx-trigger="load">
</div>
This says, "on page load, do an HTTP GET to 'http://localhost:3000/comments?post-id=example-post-id' and replace the innerHTML of this div with what comes back".

The get-comments-handler function from our express example above delegates that request to a function that looks like get-comments below:

(defn serialize-comment
  [comment-body]
  (let [author (if (any [nil? empty?] (:author comment-body))
                 "Anonymous"
                 (:author comment-body))
        author-section [:p {:class "name"} [:strong author] "said..."]
        message-section [:p {:class "message"} (:message comment-body)]
        date-section [:p {:class "datetime"} (format-iso-date (:time comment-body))]]
    [:div {:class "comment"} author-section message-section date-section]))

(defn get-comments
  "Retrieves a list of comments as HTML."
  [config post-id]
  (promesa/let [cmts (comments-repo/get-comments config post-id)
                html-comments (map serialize-comment cmts)]
    (hiccup/html html-comments)))

So the backend returns the list of comments to the frontend, rendered as HTML and htmx does the work to update the DOM without any fancy frontend frameworks. I think that's pretty cool.

Comment storage

One technique I used here to make this easier to develop was I first built the whole thing running locally without any reliance on AWS infrastructure. I generally like to do this if possible when I am using AWS because it makes the feedback loop much, much faster. This is a really basic example of the usefulness of polymorphism, which Clojure multimethods are great for.

For example, the get-comments function is a multimethod that dispatches on the :repo value for its first argument:

(defmulti get-comments :repo)
The "local" implementation of this just returns an in-memory vector:
(def comments (atom []))

(defn list-comments
  [post-id]
  (reverse (filter #(= (:post-id %) post-id) @comments)))

(defmethod repo/get-comments :local
  [_ post-id]
  (list-comments post-id))
The DynamoDB implementation of this obviously has to do a little more work, and this approach made it easy to swap that in once I was ready to focus on that part.

Recaptcha

I used Google reCAPTCHA v3 to try to thwart bot abuse in a way that is not annoying to normal users. reCAPTCHA v3 is nice because it does not require any action on the part of the user- it all happens in the background. When the user tries to save a comment, the frontend submits a reCAPTCHA token with the request. If the reCAPTCHA score is below a certain threshold, the backend responds with an error and doesn't save the comment.

Closing thoughts

The DynamoDB table and AWS Lambda configuration are packaged up in a serverless.yml file (used by Serverless Framework) so if you like, you can easily deploy your own instance to your own AWS account.

And that's pretty much how it works. Check out the code here if you want to dig into the nitty gritty. There's instructions in there for how you can deploy it in your own AWS environment and/or run it locally. And check out the end product below.

Leave a comment

Comments