tcds.dev

Learning Clojure from Scratch (Part 4) - Make It Dynamic

November 2, 2026

Part 4 of 5 · Learning Clojure from Scratch

This is part four of a series teaching Clojure to people with little or no programming experience. In Part 3, you built a few static pages that look the same for every visitor.

Now we'll build a small dynamic page, one that changes depending on what the visitor asks for. It converts temperatures in Celsius to Fahrenheit. You'll also meet the REPL, the "smarter way of working" mentioned in Part 1.

Step 1: Pull values out of the URL

First, bring in Clojure's string functions. Update the ns at the top of src/app/core.clj:

clojure
(ns app.core
  (:require [org.httpkit.server :as http]
            [clojure.string :as str])
  (:gen-class))

Now we can use str/split, str/blank?, and friends.

Then add these two functions above handler:

clojure
(defn url-decode [s]
  (java.net.URLDecoder/decode s "UTF-8"))

(defn parse-query [query-string]
  (when-not (str/blank? query-string)
    (into {}
          (for [pair (str/split query-string #"&")
                :let [[k v] (str/split pair #"=" 2)]]
            [(url-decode k) (url-decode (or v ""))]))))

Save the file. Let's take it apart:

  • URLs can't contain spaces or most symbols, so browsers encode them. A space becomes + and < becomes %3C. url-decode puts them back to their original form.
  • parse-query splits the text on & to get one chunk per parameter (a=1&b=2 becomes a=1 and b=2). #"&" is a pattern: it means "wherever you find an &".
  • for goes through the chunks one at a time and builds a new collection from them. Think of it as "for each chunk, do this".
  • For each chunk it splits on the first = to get a key and a value. [k v] is a shortcut that takes the two pieces apart and names them k and v. If the chunk has no = at all (as in ?celsius), there is no v, so (or v "") uses empty text instead.
  • It decodes both, and into {} collects the key/value pairs into a map.
  • when-not runs its body only if the condition is false. If the query string is blank, there is nothing to parse, so parse-query returns nil, which means "nothing here". That's fine: looking something up in nil just gives you nil back.

Try it in the REPL

How do you know parse-query works? You could wire it into handler, restart the server and visit a URL. Or you can just call it.

Open a new terminal (Terminal > New Terminal) and run:

sh
clojure

You'll get a prompt that looks like user=>. This is a REPL (Read, Evaluate, Print, Loop): you type a Clojure expression, it runs it and shows you the result. Type or paste each expression below into the REPL and press Enter. The ;; lines show what you should see back, so you don't need to type them.

clojure
(require 'app.core)
;; nil

(app.core/parse-query "celsius=20&unit=c")
;; {"celsius" "20", "unit" "c"}

(app.core/parse-query "")
;; nil

require loads your file. The ' in front of app.core tells Clojure "don't try to look it up right now". To call one of your functions, put its namespace in front: app.core/parse-query is the parse-query from src/app/core.clj.

No server, no browser and no restart. REPL-driven development lets you iterate and explore your code with instant feedback. In this series we'll use the REPL directly by typing text in a terminal window, but if you installed Calva in Part 1, it can also run code straight from your editor.

Leave the REPL open: we'll come back to it in the next step. Later, when you're done with it, close it by pressing Ctrl+D.

Step 2: Convert Celsius to Fahrenheit

The formula to convert from Celsius to Fahrenheit is F = C × 9/5 + 32. Add it above handler:

clojure
(defn celsius->fahrenheit [c]
  (+ (* c 9/5) 32))

(* c 9/5) means "c times 9/5", and (+ ... 32) adds 32 to that. In Clojure the operation comes first, just like any other function: (+ 1 2) is 1 plus 2.

Save the file and go back to the REPL. It doesn't notice that the file changed, so ask it to load the file again (note the :reload), then try a few temperatures:

clojure
(require 'app.core :reload)
;; nil

(app.core/celsius->fahrenheit 20.0)
;; 68.0

(app.core/celsius->fahrenheit 100.0)
;; 212.0

(app.core/celsius->fahrenheit -40.0)
;; -40.0

It works! -40 is the point where the temperature is the same in both scales.

Wire it to the URL

On the website, the number won't come from you typing 20.0. It will come from the URL, through parse-query. Let's try that. get looks up a key in a map, so this pulls the value of "celsius" out of what parse-query returned:

clojure
(get (app.core/parse-query "celsius=20") "celsius")
;; "20"

Now give that value to celsius->fahrenheit:

clojure
(app.core/celsius->fahrenheit (get (app.core/parse-query "celsius=20") "celsius"))
;; Execution error (ClassCastException) at app.core/celsius->fahrenheit (core.clj:...).
;; class java.lang.String cannot be cast to class java.lang.Number ...

The error tells you what happened (String cannot be cast to Number) and where (celsius->fahrenheit).

Look closely at the result of the get above: it's "20", with quotes. That's text, not the number 20. celsius->fahrenheit expects a number, and parse-query returns text even when it looks like a number. We need to convert it.

parse-double converts text to a number. It comes with Clojure, so it doesn't need the app.core/ prefix:

clojure
(parse-double "20")
;; 20.0

Now it should work:

clojure
(app.core/celsius->fahrenheit (parse-double "20"))
;; 68.0

Step 3: Run the server from the REPL

So far you've run the server in its own terminal with clojure -M:run, and restarting it meant Ctrl+C and typing the command again. You already have a REPL open, so let's run the server from there instead and do everything in one place.

If the server from Part 3 is still running in another terminal, stop it with Ctrl+C. Two programs can't both use port 8080 at the same time.

At the bottom of src/app/core.clj, replace -main with:

clojure
(defonce server (atom nil))

(defn stop-server []
  (when-let [stop @server]
    (stop)
    (reset! server nil)))

(defn start-server []
  (stop-server)
  (let [port (Integer/parseInt (or (System/getenv "PORT") "8080"))]
    (reset! server (http/run-server handler {:port port}))
    (println (str "Server running on port " port))))

(defn -main [& _args]
  (start-server))

There is a lot of code here, so let's go slowly:

  • atom is a box that holds a value that can change. Ours starts out holding nil (nothing), and it will hold the running server.
  • defonce is like def, but it only happens once. A :reload runs every line of the file again, and without defonce the box would be replaced by an empty one, and we'd lose track of a server that is still running.
  • @server looks inside the box, and reset! puts a new value in it. The ! at the end of a name is a convention that warns "this may change something".
  • http/run-server starts the server and gives back a function that stops it.
  • stop-server uses when-let: if the box holds something, name it stop, call it, and empty the box. If the box is empty, there's nothing to stop, so it does nothing.
  • start-server always calls stop-server first. That's what makes it safe to run as many times as you like: every call is a restart.
  • The let with port is the same code that used to be in -main, moved into start-server.
  • -main now just calls start-server, so clojure -M:run and Vercel still work exactly as before.

Save the file. Then, in the REPL, load the new code and start the server:

clojure
(require 'app.core :reload)
;; nil

(app.core/start-server)
;; Server running on port 8080

Open http://localhost:8080: it's your website, running from the REPL.

From now on, this is the rhythm: edit, save, reload, restart. Reloading is the require with :reload, and restarting is (app.core/start-server). You can run both in one go by pasting them on the same line:

clojure
(require 'app.core :reload) (app.core/start-server)

To stop the server, run (app.core/stop-server). Closing the REPL with Ctrl+D stops it too.

Step 4: Build the page

Let's put this conversion to work in a page. Add this function above handler:

clojure
(defn convert-page [params]
  (let [c (parse-double (get params "celsius"))]
    (page "Celsius to Fahrenheit"
          (str "<h1>Celsius to Fahrenheit</h1>"
               "<p>" c "&deg;C is " (celsius->fahrenheit c) "&deg;F</p>"))))
  • let gives a name to a value: c is the number parse-double got from the text.
  • (get params "celsius") is the same lookup you just tried in the REPL. In Part 3, (:uri req) worked because :uri is a keyword. The keys in params are text ("celsius"), and text can't look itself up, so we use get.
  • &deg; is HTML's way of writing the degree sign.

And add a route to handler, before the 404 at the end:

clojure
(defn handler [req]
  (case (:uri req)
    "/"        (html-response 200 (home-page))
    "/about"   (html-response 200 (about-page))
    "/convert" (html-response 200 (convert-page (parse-query (:query-string req))))
    (html-response 404 (not-found-page))))

Save the file, then reload and restart in the REPL:

clojure
(require 'app.core :reload) (app.core/start-server)

Then visit http://localhost:8080/convert?celsius=20. You should see 20.0°C is 68.0°F. Try celsius=100 and celsius=-40 too.

Now try two addresses a visitor could easily go to:

  • Visit /convert with nothing after it.
  • Visit /convert?celsius=banana.

Both of these produce an error page, with messages like Expected string, got nil and Cannot invoke "Object.getClass()" because "x" is null. The REPL will show you the full error message.

Step 5: Fail gracefully

There are two different problems here:

  1. No value at all. /convert has no celsius, so get returns nil, and parse-double refuses it: that's the Expected string, got nil error.
  2. A value that isn't a number. parse-double doesn't crash on "banana". It returns nil, its way of saying "this isn't a number". The crash comes one step later, when celsius->fahrenheit tries to do math with nil: that's the Object.getClass() error.

In both cases there's nothing to convert, so the page should simply show no result. Replace convert-page with:

clojure
(defn convert-page [params]
  (let [celsius-str (get params "celsius" "")]
    (page "Celsius to Fahrenheit"
          (str "<h1>Celsius to Fahrenheit</h1>"
               (when-let [c (parse-double celsius-str)]
                 (str "<p>" c "&deg;C is " (celsius->fahrenheit c) "&deg;F</p>"))))))
  • (get params "celsius" "") has a third part: a default. If there's no "celsius" in the map, get returns "" (empty text) instead of nil. parse-double is happy with empty text: it isn't a number, so it returns nil. That turns the first problem into the second one.
  • when-let, which you saw in stop-server, handles the second problem. It runs (parse-double celsius-str), and if the result is a number, it names it c and builds the result paragraph. If the result is nil, it skips its body and returns nil, and str treats nil as empty text, so the page shows no result at all.

Notice how nil did the work here. We didn't stop it from showing up, we only did the math when there was something.

Save the file, then reload and restart in the REPL ((require 'app.core :reload) (app.core/start-server)) and try the two broken URLs again. Now both give you the page, without a result.

Step 6: Add a form

Nobody wants to edit URLs by hand. Let's add a form so visitors can type a value and press a button. Replace convert-page with:

clojure
(defn convert-page [params]
  (let [celsius-str (get params "celsius" "")]
    (page "Celsius to Fahrenheit"
          (str "<h1>Celsius to Fahrenheit</h1>"
               "<form method=\"get\" action=\"/convert\">"
               "<input type=\"text\" name=\"celsius\" placeholder=\"Enter Celsius\">"
               "<button type=\"submit\">Convert</button>"
               "</form>"
               (when-let [c (parse-double celsius-str)]
                 (str "<p>" c "&deg;C is " (celsius->fahrenheit c) "&deg;F</p>"))))))

The <form> sends the visitor to /convert?celsius=whatever-they-typed, which is the same address you've been typing by hand. The box comes back empty after they press the button, but the result line shows the number they converted.

Save the file, then reload and restart in the REPL ((require 'app.core :reload) (app.core/start-server)) and use the form.

Checkpoint: you've now got an interactive page, one that responds differently depending on what a visitor types.

The whole file

Here is the full src/app/core.clj at this point. If something isn't working, compare yours to this one:

clojure
(ns app.core
  (:require [org.httpkit.server :as http]
            [clojure.string :as str])
  (:gen-class))

(defn page [title body]
  (str "<!DOCTYPE html><html><head><title>" title "</title>"
       "<style>body{font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px}"
       "nav a{margin-right:12px}</style></head><body>"
       "<nav><a href=\"/\">Home</a><a href=\"/about\">About</a></nav>"
       body
       "</body></html>"))

(defn home-page []
  (page "Home"
        (str "<h1>Hi, I'm Carvalho</h1>"
             "<p>Welcome to my corner of the internet.</p>"
             "<ul>"
             "<li><a href=\"https://github.com\">GitHub</a></li>"
             "</ul>")))

(defn about-page []
  (page "About"
        (str "<h1>About Me</h1>"
             "<p>I'm learning Clojure and building my first website!</p>")))

(defn not-found-page []
  (page "Not Found" "<h1>404 - Page Not Found</h1>"))

(defn url-decode [s]
  (java.net.URLDecoder/decode s "UTF-8"))

(defn parse-query [query-string]
  (when-not (str/blank? query-string)
    (into {}
          (for [pair (str/split query-string #"&")
                :let [[k v] (str/split pair #"=" 2)]]
            [(url-decode k) (url-decode (or v ""))]))))

(defn celsius->fahrenheit [c]
  (+ (* c 9/5) 32))

(defn convert-page [params]
  (let [celsius-str (get params "celsius" "")]
    (page "Celsius to Fahrenheit"
          (str "<h1>Celsius to Fahrenheit</h1>"
               "<form method=\"get\" action=\"/convert\">"
               "<input type=\"text\" name=\"celsius\" placeholder=\"Enter Celsius\">"
               "<button type=\"submit\">Convert</button>"
               "</form>"
               (when-let [c (parse-double celsius-str)]
                 (str "<p>" c "&deg;C is " (celsius->fahrenheit c) "&deg;F</p>"))))))

(defn html-response [status body]
  {:status  status
   :headers {"Content-Type" "text/html"}
   :body    body})

(defn handler [req]
  (case (:uri req)
    "/"        (html-response 200 (home-page))
    "/about"   (html-response 200 (about-page))
    "/convert" (html-response 200 (convert-page (parse-query (:query-string req))))
    (html-response 404 (not-found-page))))

(defonce server (atom nil))

(defn stop-server []
  (when-let [stop @server]
    (stop)
    (reset! server nil)))

(defn start-server []
  (stop-server)
  (let [port (Integer/parseInt (or (System/getenv "PORT") "8080"))]
    (reset! server (http/run-server handler {:port port}))
    (println (str "Server running on port " port))))

(defn -main [& _args]
  (start-server))

Deploy it

Same command as before:

sh
vercel deploy --prod

Open /convert on your live link and try the form. Then throw some bad input at it: no value at all, and banana. Both should give you a normal page.

If something went wrong

  • Could not locate app/core in the REPL: you started clojure outside your my-site folder. Press Ctrl+D, close that terminal, and open a new one with Terminal > New Terminal. VS Code starts it inside my-site.
  • The browser shows old content: the REPL keeps its own copy of the code. Save the file, then reload and restart ((require 'app.core :reload) (app.core/start-server)).
  • Address already in use when you run (app.core/start-server): another program is using port 8080, most likely a clojure -M:run still running in another terminal. Stop it with Ctrl+C and try again.
  • Syntax error (ClassNotFoundException) mentioning app.core: this REPL hasn't loaded your file yet. Run (require 'app.core) first. A new REPL always needs it.
  • No such var: app.core/start-server: the REPL hasn't loaded your latest code. Save the file and run the require with :reload again.
  • Unable to resolve symbol: url-decode (or parse-query, celsius->fahrenheit...): the function is defined below the code that uses it. Move it above.
  • No such namespace: str: the [clojure.string :as str] line is missing from the ns at the top of the file.
  • Expected string, got nil on the page: parse-double got nil. Check that convert-page uses (get params "celsius" ""), with the "" default.
  • Cannot invoke "Object.getClass()" on the page: convert-page is doing math on nil. Check that the result paragraph is inside the when-let from Step 5.

What you learned

  • Everything that comes from a URL is text. parse-query turns celsius=20 into a map, and parse-double turns the text "20" into a number.
  • when-let runs some code only when a value is there.
  • get can take a default, like (get params "celsius" ""), for when the key isn't in the map.
  • The REPL lets you test your functions without having to wire them to the rest of the app and restart the server.
  • An atom holds a value that changes, like the running server. start-server stops any old server before starting a new one, so restarting from the REPL is one line you can run again and again.

Next

So far, every page is a function you wrote. In the next part we'll build pages out of data: a blog that lists whatever files happen to be in a folder.