Crash Course - Web Dev with Clojure and Luminus

Table of Contents

This article teaches you how to make a simple web application with Clojure and Luminus. It will be a traditonal web application, i.e. the HTML is generated from templates on the back-end and no javascript requirement.

First we build a minimal app which reads and writes to a database. Then we move onto some middleware and user authentication.

Where you see:

Note I am using Debian Bullseye as my development system and these instructions are written for that. Also I assume you have a working knowledge of Clojure.

1. Quickstart; a minimal Luminus app

1.1. Installation

apt install clojure leiningen postgresql

Template out a luminus project called "polls", using the postgres app profile:

lein new luminus polls +postgres
cd polls

Luminus's deps are outdated and formatting messed-up out-of-the-box so you may want to run lein-ancient and cljfmt at this point. Instructions here.

Setup git:

# keep empty directories
find . -depth -type d -empty -exec touch {}/.gitkeep \;

# stop calva stuff ending up in git
cat >> .gitignore <<EOF

git init
git add .
git commit -m "new luminus project"

Fire-up the repl:

lein with-profile default repl

The "default" profile is a composite which includes the dev and user profiles. When the repl starts up you will be in the user namespace by default (see env/dev/clj/user.clj). This provides functions specifically to help with dev, such as start and stop.

🛈 you can just run lein repl and it will default to the default profile

Now start your app:


🚩 for future reference if you see errors in your REPL it can be because you forgot to run (start).

See your app running at http://localhost:3000/.

1.2. Create our dev database

When you ran start it printed a warning database connection URL was not found. Fix it thus (you're gonna want a second terminal at this point):

sudo -u postgres psql
create database polls_dev;
create user polls_dev with password 'password';
grant all on database polls_dev to polls_dev;

🛈 If you are using postgres 15+ see here Schema issues with PostgreSQL 15.

Now update dev-config.edn with the database url. (restart) the app. You should no longer see the warning.

1.3. Database migrations and queries

Now we are ready to start making our app. The first thing we will do is design our database tables.

Delete the example migrations in resources/migrations/.

rm resources/migrations/*

Make new migrations:

(create-migration "question-and-choice")

It has now created new migration files in resources/migrations/.

🛈 we have access to the create-migration function because we are in the user namespace.

🛈 if it errors you may need to run (start) in your user namespace.

We now add SQL to the migrations to make (and unmake) our database tables.

In up:

create table question (
  question_id serial not null primary key,
  question_text text,
  pub_date timestamp default current_timestamp


create table choice (
  choice_id serial not null primary key,
  question_ref integer references question (question_id) on delete cascade,
  choice_text text,
  votes integer default 0 not null

In down:

drop table choice;


drop table question;

Run the migration:


🚩 If you run the (migrate) and see error "Too many update results were returned", you forgot to put --;; between the queries.

🛈 you may also (rollback).

Let's define a couple of queries. We do this by making a HugSQL SQL file. Add the following to resources/sql/queries.sql (you may delete the example queries that are already there):

-- :name create-question! :! :n
-- :doc creates a new question
insert into question
values (:question_text)

-- :name get-questions :? :*
-- :doc retrieves all questions
select * from question

🛈 This file will be parsed to create functions by the code in src/clj/polls/db/core.clj.

To make your repl pull in the changes without restarting it:


🚩 remember to do this when you write more HugSQL queries in future

Now you can test out your new query functions:

(create-question! {:question_text "Would you love a monsterman?"})
(create-question! {:question_text "What time is it?"})

Now is a good time to commit your work to git.

1.4. Understanding Luminus app structure

Most of the fun is in two directories:

1.4.1. src/clj/polls/

Go to src/clj/polls/ and take a look around.

There are many bits of code here that you don't immediately need to understand and you can ignore them for now. The bits you need to pay attention to are:


  • This contains your route namespaces. You can create as many as you like and the routes will be merged. Reasons to create a new route namespace would include:
    • you may apply different middleware to different routes, for example to make certain routes authenticated
    • organizational reasons, for example putting API in a different namespace than your web routes


  • You can add your middleware here, for example to do authentication.


  • This is the database namespace. Any namespace where you want to do database interraction will need to import this. And you will likely switch to this namespace in your repl to test out your queries.

1.4.2. resources/

Go into resources/ and take a look around.


  • This contains all your HTML templates. These use Selmer. You will create a file here for every page on your site.


  • This is for static files such as CSS etc.


  • We already discussed this but don't forget this is where all your queries are!

1.5. Your first page

For your first page you will update the code and templates so that / shows the list of questions.

Firstly let's make our templates. Delete the example templates in resources/html/, except error.html which you may keep.

Selma allows us to use template inherritance. You almost certainly want to use that unless your app is just one page so we add our base.html first:

<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>My Polls app</title>

    <!-- styles -->
    {% style """/css/screen.css" %}
    <!-- put your navbar here -->

    <div class="container">
      {% block content %}
      {% endblock %}

    <!-- scripts you want in every page go here -->


    <!-- pages can include their own scripts here -->
    {% block page-scripts %}
    {% endblock %}

Now add a questions.html, which inherrits base.html. It takes a variable questions and loops over it to render the HTML list:

{% extends "base.html" %}
{% block content %}
      <div class="content">
          {% for question in questions %}
            {{question.pub_date|date:"yyyy-MM-dd"}}: {{question.question_text}}
          {% endfor %}
{% endblock %}

Now let's write our code. Edit the home-page function in src/clj/polls/routes/home.clj:

(defn home-page [request]
  (layout/render request "questions.html" {:questions (db/get-questions)}))

🛈 see how the template takes a map of keys and values, to substitute into the template. we pass the output of our get-questions query.

Now when you reload your site you can see the awesome result! http://localhost:3000/.

🚩 if you get an error that makes you think it isn't using the latest version of you code just kill the repl and restart it.

Commit that to git.

2. Fleshing-out our app

Now that you know the basics, I give you some tasks to figure out yourself. Feel free to look at my changes ✎ to see how I did it.

🏃 Add queries to create and list choices. Test them by adding some choices to your questions and listing them.

🛈 the HugSQL docs should be useful


🏃 Make an extra page to show the choices for a question, based on a question id in a query string. Make the questions on the homepage link to it.

🛈 you will need to get the query-string from the request object. That object is mega so I made this reference: Luminus Request Object

🛈 this document will help you with your routing Luminus Routing


🏃 Now make a page that shows the poll results (vote counts), based on a question id in a query string.


The next step is to allow to vote on the polls. This isn't trivial to work out for yourself so for this bit I show you my code and explain it a bit:


Regarding detail.html; note the {% csrf-field %}. This adds a hidden form input containing an anti-forgery token which Luminus will validate.

Regarding home.clj; see how we can add a :post method to a URL in addition to it's get method.

See where we return (response/found ...) instead of (layout/render request ...). We use this to redirect to the results page after voting.

3. Adding users and authentication

3.1. tables, queries and functions

We will start by adding the database tables and queries to create and authenticate users. Run this in the user namespace:

(create-migration "user")

In up:

create table users (
  login text primary key,
  password text not null,
  created_at timestamp not null default now()

In down:

drop table users;

Run the migration in the user namespace:


Commit to git.

Then add code like this to add the authentication functions:


In our authenticate-user function you may need to recap on Associative Destructuring to understand what we are doing there. Also see how it returns the user info with the password removed.

After restarting to pull in the deps you may test it:

(in-ns 'polls.auth-functions)
 (create-user! "testuser" "testpass")
(authenticate-user "testuser" "testpass")
(authenticate-user "testuser" "wrongpass")

3.2. login page

Add code thus:


Note we made a seperate routes file because we will be applying a middleware to the main routes for authorization.

If you want more info about the (assoc :session stuff see here Sessions and Cookies

You may now test your page with invalid or valid credentials to see what happens. If you login successfully then it will associate the user login with the session.

3.3. middleware

We can log in now but there's no reason to do so as the site works without. So we need to add middleware to redirect to the login page if someone tries to access the polls.

See the docs:

Now write your code thus:


Copyright 2022 Joseph Graham (joseph@xylon.me.uk)