4  Web Server

Clay launches a web server to serve the HTML pages made from Clojure namespaces. We can also host an API in that web server as a lightweight web app backend. This can be useful when prototyping or building interactive experiences.

Key features:

Clay serves on http://localhost:1971/ by default. We can call server/url to confirm where Clay is running.

(require '[scicloj.clay.v2.server :as server])
(server/url)
"http://localhost:1972/"

While the Clay server is running, it shows the last made namespace at /. To view other pages, you can navigate to a particular page, for example, this page is at /clay_book.webserver.html, so if you run Clay locally and make this notebook, you can navigate to it at http://localhost:1971/clay_book.webserver.html

4.1 Servable namespaces

When you request a servable namespace, Clay evaluates the namespace to return HTML, rather than serving a static file.

^:kind/code
(slurp "notebooks/clay_book/current_time.clj")
^:kindly/servable
(ns clay-book.current-time)

;; The namespace is annotated with `^:kindly/serve`,
;; so this notebook is evaluated every time it is viewed.

(str (java.time.LocalTime/now))

;; We can define some persistent state:

(defonce state (atom 0))

(def messages
  ["You will have good luck today"
   "A pleasant surprise awaits you"
   "Your hard work will pay off"])

;; Make use of that state:

^:kind/hiccup
[:div
 [:strong "Welcome!" (messages @state)]]

;; Modify the state every time the page is viewed:

(swap! state (fn [x]
               (mod (inc x) (count messages))))

;; So that the message cycles every time you view the notebook.

The current_time.clj notebook is evaluated when we visit the URL http://localhost:1971/notebooks/clay_book/current_time.html because the namespace is annotated as ^:kindly/servable.

Servable namespaces are a way to keep a notebook current, rather than serving a static snapshot. So if you have a report, it can always pull the latest data.

4.2 Servable functions

Functions behave as endpoints that can be called by an HTTP request when they have the metadata flag ^:kindly/servable.

4.2.1 Accepting positional args

(defn ^:kindly/servable kindly-add [a b]
  (+ a b))
Note

Unlike other Kindly annotations, the servable function metadata must be placed on the Var.

Wrong
^:kindly/servable (defn add [a b] (+ a b))
Correct
(defn ^:kindly/servable add [a b] (+ a b))

HTTP requests to /kindly-compute will be handled by Clay, which will call the function and return the result in the response. The function to call must be a fully qualified symbol that resolves to an annotated function. The function name should be placed in the URL path. So to call the clay-book.webserver/kindly-add function, we use the URL http://localhost:1971/kindly-compute/clay-book.webserver/kindly-add A sequence of arguments :args in the params will be applied to the function call.

(def kindly-add-response
  @(http/post (str (server/url) "kindly-compute/clay-book.webserver/kindly-add")
              {:body (json/write-str {:args [2 3]})
               :headers {"content-type" "application/json"}}))
Note

If you prefer, you can pass the function name as a param called :func rather than the URL path.

This is the request that we made:

(:opts kindly-add-response)
{:body "{\"args\":[2,3]}",
 :headers {"content-type" "application/json"},
 :method :post,
 :url
 "http://localhost:1972/kindly-compute/clay-book.webserver/kindly-add"}

And this is the response we got:

(dissoc kindly-add-response :opts)
{:body "5",
 :headers
 {:content-length "1",
  :content-type "application/json; charset=utf-8",
  :date "Sat, 10 Jan 2026 23:29:03 GMT",
  :server "http-kit",
  :x-content-type-options "nosniff",
  :x-frame-options "SAMEORIGIN"},
 :status 200}

The answer "5" is in the :body, and the :content-type is JSON.

(:body kindly-add-response)
"5"
(:content-type (:headers kindly-add-response))
"application/json; charset=utf-8"
(json/read-str (:body kindly-add-response))
5

4.2.2 Accepting a single map of params

(defn ^:kindly/servable kindly-add-named [{:keys [a b]}]
  (+ a b))

When we call this function, we provide params. Rather than {:args [2 3]} we are now passing {:a 4 :b 5}:

(def kindly-add-named-response
  @(http/post (str (server/url) "kindly-compute/clay-book.webserver/kindly-add-named")
              {:body (json/write-str {:a 4 :b 5})
               :headers {"content-type" "application/json"}}))
kindly-add-named-response
{:opts
 {:body "{\"a\":4,\"b\":5}",
  :headers {"content-type" "application/json"},
  :method :post,
  :url
  "http://localhost:1972/kindly-compute/clay-book.webserver/kindly-add-named"},
 :body "9",
 :headers
 {:content-length "1",
  :content-type "application/json; charset=utf-8",
  :date "Sat, 10 Jan 2026 23:29:03 GMT",
  :server "http-kit",
  :x-content-type-options "nosniff",
  :x-frame-options "SAMEORIGIN"},
 :status 200}
(json/read-str (:body kindly-add-named-response))
9
Note

Servable functions can be placed in any resolvable namespace, they don’t have to be inside a notebook.

4.3 Calling kindly-compute from a browser

When using Scittle, Clay defines a convenience function kindly-compute to make a request. The result will be delivered to a callback handler function.

^:kind/scittle
'(defn handler [result]
   (js/console.log "kindly-compute result:" result))

Requesting a function with positional args:

^:kind/scittle
'(kindly-compute 'clay-book.webserver/kindly-add
                 [11 20]
                 handler)

Requesting a function with a single params argument:

^:kind/scittle
'(kindly-compute 'clay-book.webserver/kindly-add-named
                 {:a 8, :b 11}
                 handler)

If you prefer to write your own client-side code, it might look something like this:

^:kind/hiccup
[:script
 "fetch('/kindly-compute/clay-book.webserver/kindly-add-named', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({a: 99, b: 1})
  })
    .then(resp => resp.json())
    .then(result => console.log('Javascript call to kindly-add-named got', result));"]

The Clay server must be running for browser requests to succeed.

4.4 Handler endpoints

Sometimes a little more control over the request/response handling is required. A handler is a function that takes a request like {:uri "/about.html"} and returns a response like {:body "hello"}.

(defn ^:kindly/handler handle-add [req]
  (let [{:keys [params request-method]} req
        {:keys [a b]} params]
    (println "Request handle-add" request-method)
    (pprint/pprint req)
    {:body (+ a b)}))

The handle-add function prints out the request so we can see that the handler has access to the entire request handling context.

(def handle-add-response
  @(http/post (str (server/url) "kindly-compute/clay-book.webserver/handle-add")
              {:body (json/write-str {:a 5, :b 9})
               :headers {"content-type" "application/json"}}))
NoteTHREAD OUT
Request handle-add :post
{:cookies {},
 :remote-addr "127.0.0.1",
 :start-time 227470302705783,
 :params {:b 9, :a 5},
 :flash nil,
 :body-params {:b 9, :a 5},
 :headers
 {"accept-encoding" "gzip, deflate",
  "content-length" "13",
  "content-type" "application/json",
  "host" "localhost:1972",
  "user-agent" "http-kit/2.0"},
 :async-channel
 #object[org.httpkit.server.AsyncChannel 0x262e288f "/127.0.0.1:1972<->/127.0.0.1:42542"],
 :server-port 1972,
 :muuntaja/request
 {:format "application/json",
  :charset "utf-8",
  :raw-format "application/json"},
 :content-length 13,
 :form-params {},
 :websocket? false,
 :session/key nil,
 :query-params {},
 :content-type "application/json",
 :character-encoding "utf8",
 :uri "/kindly-compute/clay-book.webserver/handle-add",
 :server-name "localhost",
 :query-string nil,
 :muuntaja/response
 {:format "application/json", :charset "utf-8", :raw-format nil},
 :body
 #object[org.httpkit.BytesInputStream 0x3c89748c "BytesInputStream[len=13]"],
 :multipart-params {},
 :scheme :http,
 :request-method :post,
 :session {}}

The request was printed as a side-effect of calling the endpoint. Handlers have access to the session, cookies, and everything about the request.

handle-add-response
{:opts
 {:body "{\"a\":5,\"b\":9}",
  :headers {"content-type" "application/json"},
  :method :post,
  :url
  "http://localhost:1972/kindly-compute/clay-book.webserver/handle-add"},
 :body "14",
 :headers
 {:content-length "2",
  :content-type "application/json; charset=utf-8",
  :date "Sat, 10 Jan 2026 23:29:03 GMT",
  :server "http-kit",
  :x-content-type-options "nosniff",
  :x-frame-options "SAMEORIGIN"},
 :status 200}
(json/read-str (:body handle-add-response))
14

4.5 Params

Params may be placed in the query-string or in the body of the request.

4.5.1 Request Formats

Requests with body params must set a Content-Type header to indicate the format. Clay supports:

  • application/x-www-form-urlencoded (form params)
  • application/json (JSON)
  • application/edn (EDN)
  • application/transit+json (Transit JSON)
  • application/transit+msgpack (Transit MessagePack)

4.5.2 Response Formats

To receive a response in a specific format, set the Accept header. Clay supports:

  • application/json (JSON, the default)
  • application/edn (EDN)
  • application/transit+json (Transit JSON)
  • application/transit+msgpack (Transit MessagePack)

Content negotiation is performed by Muuntaja middleware, which is configured in scicloj.clay.v2.server/clay-handler.

4.6 HTML Responses

Endpoint response bodies are encoded based on an “Accept” header if present, or in JSON by default. However when a servable function name ends in html, then the response will be Content-Type: text/html instead. This differentiates between endpoints that return data, and endpoints that return HTML pages.

(defn ^:kindly/servable greet-html [{:keys [name]}]
  (page/html5 [:h1 (str "Hello " name)]))

We can GET the page with params in the query string ?name=world:

(def greet-response
  @(http/get (str (server/url) "kindly-compute/clay-book.webserver/greet-html?name=world")))
greet-response
{:opts
 {:method :get,
  :url
  "http://localhost:1972/kindly-compute/clay-book.webserver/greet-html?name=world"},
 :body "<!DOCTYPE html>\n<html><h1>Hello world</h1></html>",
 :headers
 {:content-length "49",
  :content-type "text/html; charset=utf-8",
  :date "Sat, 10 Jan 2026 23:29:03 GMT",
  :server "http-kit",
  :x-content-type-options "nosniff",
  :x-frame-options "SAMEORIGIN"},
 :status 200}

We got HTML rather than JSON because the function name was greet-html

(:body greet-response)
"<!DOCTYPE html>\n<html><h1>Hello world</h1></html>"
(:content-type (:headers greet-response))
"text/html; charset=utf-8"

4.7 Dynamic handlers

Handlers are more generic than endpoints. Not all handlers are associated with a route, or maybe we just prefer to use a non-annotated handler. Clay supports these by allowing you to add handlers to the Clay server stack.

You can add a handler with install-handler!

(defn my-handler [{:keys [request-method uri]}]
  (case [request-method uri]
    [:get "/chicken"] {:status 200
                       :headers {"Content-Type" "text/plain; charset=utf-8"}
                       :body "bock bock bock"}
    nil))
(server/install-handler! #'my-handler)
#{#'clay-book.webserver/my-handler}

/chicken is a top-level route (not namespaced). We could handle / which would be necessary for a complete website. Handling / prevents Clay interactively showing the last made namespace, so only do that when deploying.

(def chicken-response
  @(http/get (str (server/url) "chicken")))
(:body chicken-response)
"bock bock bock"

Unlike most Ring handlers, Clay handlers may return nil to ignore a request, leaving it to other handlers or the site-defaults handler. You can install multiple handlers side-by-side in this way.

Clay requires a handler to be a var holding a function because that allows you to replace the definition of the handler conveniently, and for the handlers to be tracked by their var. Vars have identity, functions do not.

Because handlers and servable functions are Vars, they can be updated dynamically without restarting the server. If you need to remove an unwanted handler, server/clear-handlers! will clear all current handlers.

4.8 Clay WebSocket handler

Similar to dynamic handlers, you may also install a websocket handler:

(defn my-websocket-receive [ch msg]
  (println "Received" msg)
  (httpkit/send! ch "got it"))
(server/install-websocket-handler! :on-receive #'my-websocket-receive)
{:on-receive #{#'clay-book.webserver/my-websocket-receive}}

On the client, you can access clay_socket to send and receive messages to the server:

^:kind/hiccup
[:script
 "clay_socket.send('can you hear me?');
  clay_socket.addEventListener('message', function(event) {
    console.log('Clay socket:', event.data);
  });"]

4.9 Hosting

4.9.1 Preparing HTML

To build all the notebooks before launching the server:

clojure -M -m scicloj.clay.v2.main --render

This will produce all the HTML files and exit.

4.9.2 Serving

Configure Clay with :port 80 to listen on the default HTTP port. If you need to adjust the Ring middleware (sessions, proxy headers, CSRF, etc.), :ring-defaults to be deep-merged into Ring’s site-defaults when Clay starts its server. For example to run behind a proxy you could add :ring-defaults {:proxy true}. Configuration can be put in a clay.edn file, or passed via the command line interface.

To launch Clay as a web server from the Command Line Interface:

clojure -M -m scicloj.clay.v2.main -m "{:port 80}"

You may find it convenient to package your project as an uberjar for deployment to a hosting service.

4.9.3 Static files

Clay will serve any files in the :base-target-path ("docs" is the default). When making notebooks, any non-source files are copied from the :subdirs-to-sync (["src" "notebooks"] by default) to the :base-target-path ("docs" by default).

Clay will also serve any files found in resources/public, as provided by Ring’s site-defaults. This can be changed by configuring :ring-defaults

4.9.4 Site index file

Normally Clay uses the root route / to show the last made file. But when first launched as a server, it will show “index.html” from the :base-target-path if found. Alternatively you can install a custom handler to handle /.

4.10 Glossary

URL
https://clojurecivitas.github.io/about.html scheme + host + uri
URI
/about.html the path at the end of a URL
request-method
GET, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT, TRACE
handler
A function that takes a request like {:uri "/about.html"} and returns a response like {:body "ClojureCivitas is a shared blog space"}.
routing
Matching a request-method and a uri to determine a sub-handler that should process the request. Handlers may perform routing, and may call other handlers.
endpoint
A servable function that can be called from the frontend via HTTP.
params
Data extracted from an HTTP request, which can come from three sources:
  • URL params: query string after ?, e.g., /some/path?a=1 yields {:a "1"}
  • Form params: form-encoded body (usually from HTML form submission)
  • Body params: JSON or other format in the request body
middleware
A function that wraps a handler to modify the request before it reaches the handler, and modify the response after the handler returns. For example adding authentication or logging. The result of wrapping a handler is a handler.
frontend
Application code that runs in the browser (JavaScript or ClojureScript).
backend
Application code that runs on the server (in Clojure).

4.11 Conclusion

Clay can act as a web server for prototyping interactive dashboards, live analysis, or a web app. It’s easy to create endpoints by annotating functions and call them from the rendered notebook.

source: notebooks/clay_book/webserver.clj