tcds.dev

Learning Clojure from Scratch (Part 3) - Make It Yours

October 25, 2026

Part 3 of 5 ยท Learning Clojure from Scratch

This is part three of a series teaching Clojure to people with little or no programming experience. In Part 1 you ran a web server on your computer. In Part 2 you put it on the internet.

In this part you'll go from a website that says Hello, World! to a website with a home page, an about page, and a polite message for URLs that don't exist.

Step 1: Look at what the server receives

Before we start building the new pages, let's look at the information available to handler.

Temporarily change handler in src/app/core.clj so it sends the request back to you as text:

clojure
(defn handler [req]
  {:status  200
   :headers {"Content-Type" "text/plain"}
   :body    (pr-str req)})

Two changes: _req is now req because handler is using it, and the body is now (pr-str req), which means "print this value as text".

Remember the rhythm from Part 1: edit, stop, start, refresh. Stop the server if it's still running (Ctrl+C), start it again with clojure -M:run, and open http://localhost:8080/hello?name=carvalho. You'll see something like this (shortened here):

clojure
{:remote-addr "0:0:0:0:0:0:0:1",
 :headers {"accept" "text/html,...", "host" "localhost:8080", "user-agent" "Mozilla/5.0 ..."},
 :server-port 8080,
 :uri "/hello",
 :query-string "name=carvalho",
 :request-method :get,
 ...}

That's the request. It's a map, just like the response. Two of its keys matter for this series:

  • :uri is the part of the address after the host name: /hello. We'll use it in this part to choose which page to show.
  • :query-string is whatever comes after the ?: name=carvalho. We'll use it in the next part.

Change the address to /about or /anything-you-like and watch :uri change. Now that you know what a request contains, let's build the new pages.

Step 2: Send back HTML instead of plain text

Put handler back to saying hello (and req back to _req, since we're not looking at the request for now), but make it send HTML text. Change Content-Type from text/plain to text/html and write HTML in the body:

clojure
(defn handler [_req]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    "<h1>Hello, World!</h1>"})

Content-Type tells the browser how to read what it receives. text/plain means "show it as text", text/html means "this is an HTML page, interpret the tags". <h1>...</h1> is the HTML tag for a big heading.

Restart, refresh, and the greeting becomes a heading.

If you're new to HTML, a few tags will get you far:

  • <h1>Title</h1> for a heading.
  • <p>Some text</p> for a paragraph.
  • <a href="https://example.com">a link</a> for a link.
  • <ul> and <li> for a bulleted list: the <ul> is the list, and each <li> is one item in it.
  • <nav> for a group of navigation links. You'll see it in the next step.

Step 3: Create a reusable page template

Every page of a website needs the same frame: a title, some styling, a navigation bar, and so on. Typing that for each page would get old quickly. So let's write a function that takes the title and the content of a page and wraps them in a frame.

Add this above handler (Clojure reads a file from top to bottom, so a function has to be defined before the code that uses it):

clojure
(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>"))

page receives two things, title and body, the same way handler receives req. Most of the text inside it is the frame itself. The <style> and <nav> lines are decoration, so you don't need to understand them yet. Just type them in.

Two things to notice:

  • str joins things together into one piece of text. title and body get dropped between the other pieces.
  • To put a double quote inside a piece of text you write \", which is why href=\"/\" looks so busy. The backslash means "literally print this next character, I'm not trying to end the text."

Now use page to write two pages. Each one is a function with no inputs (that's the empty []), that returns HTML text:

clojure
(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>")))

str shows up again here, joining the lines of each body into one piece of text before page wraps it.

Put your real name and real links in home-page. Nothing changes in the browser yet. In the next step we'll connect these pages to addresses so you can see them.

Step 4: Show the right page

You have two pages and one handler. The handler needs to look at :uri, which you saw in Step 1, and choose what page to show.

Replace handler with:

clojure
(defn handler [req]
  (case (:uri req)
    "/"      {:status  200
              :headers {"Content-Type" "text/html"}
              :body    (home-page)}
    "/about" {:status  200
              :headers {"Content-Type" "text/html"}
              :body    (about-page)}))

(:uri req) looks up the key :uri in the request map. (It's a Clojure shortcut: a keyword in the first position looks itself up in the map that follows.) case then picks the option that matches that value: "/" gets the home page and "/about" gets the about page.

Restart and visit / and /about. You have a two-page website, with a navigation bar that works.

It works, but look at the two response maps: they're identical except for the :body. Add a third page and you'd copy it a third time, and a fourth, and so on. Whenever you catch yourself repeating the same work, consider moving it to a function so it can be reused. Here's a small helper function that builds the response map. The only thing that changes between pages, the body, becomes its input. Add it above handler:

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

Now handler can shrink to this:

clojure
(defn handler [req]
  (case (:uri req)
    "/"      (html-response 200 (home-page))
    "/about" (html-response 200 (about-page))))

Restart and visit both pages again: nothing changed for the visitor, and your code is less repetitive. The status is a parameter too, which is going to be handy in the next step.

Checkpoint: your website now has more than one page.

Step 5: Handle pages that don't exist

Visit http://localhost:8080/nothing-here. Instead of something friendly, the browser shows No matching clause: /nothing-here. case needs a match, and there wasn't one, so the server gave up (and answered with status 500, the web's code for "something broke on the server").

The fix is a catch-all as the last option: a page for "I don't know that one", with status 404, the web's code for "not found". Add not-found-page above handler, and replace handler with the new version:

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

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

Look at the last line of case: it has only a result, with no match value in front of it. case treats that as the default, the answer when nothing else matched.

Restart and try /nothing-here again.

The 404 page looks like any other page, so how do you know the status really is 404? Your browser keeps track. Right-click the page, choose Inspect, open the Network tab and reload. The Status column shows 404 for /nothing-here, and 200 for / and /about. Status codes are mostly for machines, like browsers and search engines, which need to know what happened without reading the page.

Checkpoint: your website now answers every address: with a page when it knows one, and with a friendly 404 when it doesn't.

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])
  (: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 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))
    (html-response 404 (not-found-page))))

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

Deploy it

Same command as in Part 2:

sh
vercel deploy --prod

Vercel remembers your project from last time, so it shouldn't ask you the setup questions again. Open the link and click around. Visit /about, and then /nothing-here to see your 404 page live. You didn't need to change Dockerfile.vercel from Part 2: it copies the whole src folder, so your new code goes along automatically. Your website is getting more interesting!

If something went wrong

  • The browser shows old content: the running program still has the old code. Stop the server with Ctrl+C, start it again, and refresh.
  • Unable to resolve symbol: html-response (or home-page, page...): Clojure reads the file from top to bottom, so a function has to be defined above the code that uses it. Move it above handler, and check the spelling of the name.
  • Syntax error reading source or Unmatched delimiter: a parenthesis is missing or extra. Remember the "Break it on purpose" section in Part 1: the line number in the error is where to start looking.
  • The page shows <h1> and other tags as text: the Content-Type is still text/plain. It has to be text/html.
  • No matching clause: /something: case didn't find a match and has no default. Add the last line from Step 5.

What you learned

  • The request is a map too: :uri tells handler which page the visitor asked for, and :query-string holds what comes after the ?.
  • Content-Type tells the browser how to read the response. text/html makes it interpret the HTML tags.
  • str joins pieces of text, and page uses it to wrap any content in the same frame (title, styling, and navigation), so every page of your website looks similar.
  • home-page, about-page and not-found-page are functions that return HTML text, and handler uses case to pick one based on :uri.
  • When you catch yourself repeating the same work, move it into a function. html-response did that for the response map.
  • A function has to be defined above the code that uses it.
  • Status codes are part of the response: 200 means OK and 404 means not found. Without a default, case fails and the server answers with a 500.

Next

In the next part we'll make a page that responds to what a user types, and you'll finally put :query-string from Step 1 to work.