Clojure ssr app with Pedestal and Hiccup

How to create a simple clojure project from scratch and create a HTTP service, with routing and SSR.

Project setup

In an empty dir, create a deps.edn with this content:

{:paths ["src" "resources"]
 :deps  {org.clojure/clojure          {:mvn/version "1.10.1"}
         io.pedestal/pedestal.service {:mvn/version "0.5.8"}
         io.pedestal/pedestal.jetty   {:mvn/version "0.5.8"}
         hiccup/hiccup                {:mvn/version "2.0.0-alpha2"}}}
    

Let's understand these names:

Start the REPL

At this moment, we can start the REPL. Just type clj or use your editor to start one.

Then create recursively the dirs src/clj_ssr_app, then the file src/clj_ssr_app/main.clj

Let's write this main.clj

(ns clj-ssr-app.main) ;; << this should match clj_ssr_app/main.clj inside src or resources

(defn dev-start
  [& _]
  (prn :hello-world))
    

Now let's test it on REPL

(require 'clj-ssr-app.main :reload)
nil
(clj-ssr-app.main/dev-start)
:hello-world
nil
    

Ok, everything working.

Setup pedestal

Here I will explain in code comments

(ns clj-ssr-app.main
  (:require [io.pedestal.http :as http]))

;; our first handler
(defn hello
  [req]
  {:body "Hello world!"
   :status 200})

;; our route table
;; each route is an array with the fileds
;; - the path
;; - the method
;; - the handler function
;; - the keyword :route-name (we will se more about this in a moment)
;; - a unique name for this route
(def routes
  #{["/" :get hello :route-name :hello]})

(defonce http-state (atom nil))
(defn dev-start
  [& _]
  (swap! http-state (fn [st]
                      ;; if there is something running, stop it
                      (some-> st http/stop)
                      (-> {::http/routes routes
                           ::http/port   8080
                           ::http/join?  false
                           ::http/type   :jetty}
                          http/default-interceptors
                          http/dev-interceptors
                          http/create-server
                          http/start))))
    

Now back on our repl

;; reload your changes
(require 'clj-ssr-app.main :reload)
nil
;; call start again
(clj-ssr-app.main/dev-start)
    

Now, if you connect your browser on localhost:8080 your should see a "hello world" message.

HTML response

Let's work on make hello function into a HTML response

(ns clj-ssr-app.main
  (:require [io.pedestal.http :as http]
    ;; add hiccup
            [hiccup2.core :as h]))

(defn hello
  [req]
  {:body    (->> [:html
                  [:head
                   [:title "Hello world!"]]
                  [:body
                   [:p {:style {:background-color "lightgeen"}} "Hello from HTML"]]]
                 ;; hiccup2 by default generates xhtml.
                 (h/html {:mode :html})
                 ;; we need to manually prepend this to make the document valid
                 (str "\n"))
   ;; we need this headers to talk to the browser that it's a HTML response
   :headers {"Content-Type" "text/html"}
   :status  200})
;; rest of the file remains with no change
    

Ok, now back on REPL

;; reload your changes
(require 'clj-ssr-app.main :reload)
nil
;; restart the HTTP server (we will have hot-reload in a near future)
(clj-ssr-app.main/dev-start)
    

now if you refresh your browser, you will see the message in HTML :)

Interceptors

Pedestal has the concept of "interceptor", that is similar to "middlewares": help you to transform requests/responses.

Let's create a interceptor that handles HTML

(def html-response
  "If the response contains a key :html,
     it take the value of these key,
     turns into HTML via hiccup,
     assoc this HTML in the body
     and set the Content-Type of the response to text/html"
  {:name  ::html-response
   :leave (fn [{:keys [response]
                :as   ctx}]
            (if (contains? response :html)
              (let [html-body (->> response
                                   :html
                                   (h/html {:mode :html})
                                   (str "\n"))]
                (assoc ctx :response (-> response
                                         (assoc :body html-body)
                                         (assoc-in [:headers "Content-Type"] "text/html"))))
              ctx))})
    

Now we can add this interceptor to our route

;; the 3nth element can be:
;; A- A function (let's call it handler) that receive a request and return a response
;; B- An interceptor
;; C- An array of interceptors, that may end on a handler function
(def routes
  #{["/" :get [html-response hello] :route-name :hello]})
    

At this moment, if you reload/restart the http serve, everything should continue to work

But now we can simplify our handler

(defn hello
  [req]
  {:html   [:html
            [:head
             [:title "Hello world!"
              [:body
               [:p {:style {:background-color "lightgeen"}}
                "Hello from HTML"]]]]]
   :status 200})
    

After call again require/reload/dev-main stuff, you should see no difference on the browser

Routing

Let's add a new route and links between them

This section will be done in code comments

;; add [io.pedestal.http.route :as route] to your namespace
(defn hello
  [req]
  {:html   [:html
            [:head
             [:title "Hello world!"
              [:body
               [:p {:style {:background-color "lightgeen"}}
                "Hello from HTML in green"]
               ;; let's add a link to yellow page here
               [:a {:href (route/url-for :hello-yellow)}
                "go to yellow"]]]]]
   :status 200})

;; Create this new handler for :hello-yellow page
(defn hello-yellow
  [req]
  {:html   [:html
            [:head
             [:title "Hello world!"
              [:body
               [:p {:style {:background-color "lightyellow"}}
                "Hello from HTML in yellow"]
               [:a {:href (route/url-for :hello)}
                "go to green"]]]]]
   :status 200})

(def routes
  #{["/" :get [html-response hello] :route-name :hello]
    ;; and add to your routes
    ["/yellow" :get [html-response hello-yellow] :route-name :hello-yellow]})