A zero-dependency, runtime-agnostic router.
  • Clojure 99.4%
  • Shell 0.6%
Find a file
2026-02-18 00:38:31 +02:00
bench/ruuter Add Jank support 2026-02-17 23:03:14 +02:00
examples/jank-test-project/src/example Add Jank support 2026-02-17 23:03:14 +02:00
src/ruuter Add Jank support 2026-02-17 23:03:14 +02:00
test/ruuter Add Jank support 2026-02-17 23:03:14 +02:00
.gitignore 2.0: Improve performance, usability. 2026-02-17 20:29:53 +02:00
bb.edn 2.0: Improve performance, usability. 2026-02-17 20:29:53 +02:00
BENCHMARKS.md Update docs 2026-02-18 00:22:03 +02:00
CHANGELOG.md Update docs 2026-02-18 00:22:03 +02:00
deps.edn 2.0: Improve performance, usability. 2026-02-17 20:29:53 +02:00
jank_bench_runner.jank Add Jank support 2026-02-17 23:03:14 +02:00
jank_test.sh Add Jank support 2026-02-17 23:03:14 +02:00
jank_test_runner.jank Add Jank support 2026-02-17 23:03:14 +02:00
LICENSE.txt Initial commit 2021-10-02 01:26:07 -03:00
README.md Update README 2026-02-18 00:38:31 +02:00

Ruuter

A tiny, zero dependency, system-agnostic router for Clojure, ClojureScript, Babashka, Jank and NBB that operates with a simple data structure where each route is a map inside a vector. Yup, that's it. No magic, no bullshit.

Installation

Add Ruuter as a git dependency in your deps.edn:

{:deps {askonomm/ruuter {:git/url "https://git.nmm.ee/asko/ruuter.git"
                         :git/tag "v2.1.0"
                         :git/sha "d9f8ef2"}}}

Usage

Setting up

Require the namespace ruuter.core and then pass your routes to the route function along with the current request map, like this:

(ns myapp.core
  (:require [ruuter.core :as ruuter]))

(def routes [{:path "/"
              :method :get
              :response {:status 200
                         :body "Hi there!"}}])

(def request {:uri "/"
              :request-method :get})

(ruuter/route routes request) ; => {:status 200
                              ;     :body "Hi there!"}

This will attempt to match the best route for the request map and return its response. Routes are matched using best-match semantics — the most specific route always wins, regardless of the order routes appear in the vector. If no route was found, it will attempt to find a route that has a :path that is :not-found, and return its response instead. But if not even that route was found, it will simply return a built-in 404 response instead.

Note that the request-method doesn't have to be a keyword, it can be anything that your HTTP server returns. But it does have to be called request-method for the router to know where to look for. That said, you do not have to provide neither method in the route, nor request-method in the request if you don't want to. You can skip both of them and let Ruuter route based on the :uri alone if you want.

Setting up with http-kit

Now, obviously on its own the router is not very useful as it needs an actual HTTP server to return the responses to the world, so here's an example that uses http-kit:

(ns myapp.core
  (:require [ruuter.core :as ruuter]
            [org.httpkit.server :as http]))

(def routes [{:path "/"
              :method :get
              :response {:status 200
                         :body "Hi there!"}}
             {:path "/hello/:who"
              :method :get
              :response (fn [req]
                          {:status 200
                           :body (str "Hello, " (:who (:params req)))})}])

(defn -main []
  (http/run-server #(ruuter/route routes %) {:port 8080}))

Setting up with Ring + Jetty

Ring + Jetty set-up is almost identical to the one of http-kit, and looks like this:

(ns myapp.core
  (:require [ruuter.core :as ruuter]
            [ring.adapter.jetty :as jetty]))

(def routes [{:path "/"
              :method :get
              :response {:status 200
                         :body "Hi there!"}}
             {:path "/hello/:who"
              :method :get
              :response (fn [req]
                          {:status 200
                           :body (str "Hello, " (:who (:params req)))})}])

(defn -main []
  (jetty/run-jetty #(ruuter/route routes %) {:port 8080}))

Setting up with Babashka

You can also use Ruuter with Babashka, by using the built-in http-kit server, for example. Either add the dependency in your bb.edn file or if you want to make the whole thing one-file-rules-them-all, then load it in with deps/add-deps, like below:

#!/usr/bin/env bb

(deps/add-deps '{:deps {askonomm/ruuter {:git/url "https://git.nmm.ee/asko/ruuter.git"
                                         :git/tag "v2.1.0"
                                         :git/sha "d9f8ef2"}}})

(require '[org.httpkit.server :as http]
         '[babashka.deps :as deps]
         '[ruuter.core :as ruuter])

(def routes [{:path "/"
              :method :get
              :response {:status 200
                         :body "Hi there!"}}])

(http/run-server #(ruuter/route routes %) {:port 8082})

@(promise)

Setting up with Jank

Create a file (e.g. main.jank) and point --module-path at Ruuter's src directory:

(ns example.main
  (:require [ruuter.core :as ruuter]))

(def routes [{:path "/"
              :method :get
              :response {:status 200
                         :body "Hi there!"}}
             {:path "/hello/:who"
              :method :get
              :response (fn [req]
                          {:status 200
                           :body (str "Hello, " (:who (:params req)))})}])

(def request {:uri "/hello/world"
              :request-method :get})

(println (ruuter/route routes request))

Run it with:

jank run --module-path src main.jank

See the examples/jank-test-project/ directory for a complete example.

Creating routes

Like mentioned above, each route is a map inside a vector. Routes are matched using best-match semantics — the most specific route wins regardless of order.

Each route consists of three items:

:path

A string path starting with a forward slash describing the URL path to match.

To create parameters from the path, prepend a colon (:) in front of a path slice like you would with a Clojure keyword.

Required parameters

A required parameter with a string such as /hi/:name, which would match any string in its own slice. The :name itself will then be available with its value from the request passed to the response function, like this:

(fn [req]
  (let [name (:name (:params req))]
    {:status 200
     :body (str "Hi, " name)}))
Optional parameters

An optional parameter with a string such as /hi/:name?, which would match any string in its own slice, but is not required to be present. If there is a :name provided in the URI then it will then be available with its value from the request passed to the response function, like this:

(fn [req]
  (let [name (:name (:params req))]
    {:status 200
     :body (str "Hi, " name)}))
Wildcard parameters

The above-mentioned :name and :name? only match in their own path slice, e.g inside a space surrounded by two forward slashes. They cannot, by design, match the whole URL path. If you need wildcard matching, instead use :name*, which will match everything including forward slashes. A wildcard parameter must be the last segment in a path.

:method

The HTTP method to listen for when matching the given path. This can be whatever the HTTP server uses. For example, if you're using http-kit for the HTTP server then the accepted values are:

  • :get
  • :post
  • :put
  • :delete
  • :head
  • :options
  • :patch

:response

The response can be a direct map, or a function returning a map. In case of a function, you will also get passed to you the request map that the HTTP server returns, with added-in :params that contain the values for the URL parameters you use in your route's :path.

Thus, a :response can be a map:

{:status 200
 :body "Hi there!"}

Or a function returning a map:

(fn [req]
  {:status 200
   :body "Hi there!"})

What the actual map can contain that you return depends again on the HTTP server you decided to use Ruuter with. The examples I've noted here are based on http-kit & ring + jetty, but feel free to make a PR with additions for other HTTP servers.

How It Works

Under the hood, Ruuter compiles your route definitions into a segment trie (prefix tree). Each segment of a path becomes a node in the tree, with branches for literal strings, parameters, optional parameters, and wildcards. This means route matching runs in O(path-depth) time — proportional to the number of segments in the URI, not the number of routes — so performance stays constant whether you have 5 routes or 5,000.

When a request comes in, the trie is walked depth-first, trying all branches at each node in specificity order and tracking the best match found so far:

Priority Segment type Score Example
1st Literal +3 users
2nd Required param +2 :id
3rd Optional param +1 :id?
4th Wildcard +0 :path*

The route with the highest total score wins. This means you can define routes in any order and always get the expected behavior:

(def routes [{:path "/api/:resource"    :method :get :response ...}
             {:path "/api/users"        :method :get :response ...}  ; wins for /api/users
             {:path "/api/users/:id"    :method :get :response ...}
             {:path "/api/users/me"     :method :get :response ...}  ; wins for /api/users/me
             {:path "/:catch*"          :method :get :response ...}])

No regex is involved — matching is done via direct string comparison of path segments against the trie.

Pre-Compiling Routes

When you pass a routes vector to ruuter/route, the trie is compiled automatically on first use and cached via memoize. For most applications this is all you need.

If you want explicit control — for instance, to compile once at startup, to avoid the memoization cache, or to inspect the compiled structure — use compile-routes:

(def compiled (ruuter/compile-routes routes))

;; Pass the pre-compiled trie to route — no compilation step at request time
(ruuter/route compiled request)

The return value of compile-routes is a map with :trie (the segment trie) and :not-found (the fallback route, if any), tagged with metadata so route can detect it and skip recompilation.

Development

Ruuter uses deps.edn (Clojure CLI) and bb.edn (Babashka) for all development tasks.

Running Tests

# JVM (Clojure)
clojure -M:test

# ClojureScript (Node.js)
clojure -M:cljs-test

# Babashka
bb test

# Jank
./jank_test.sh

Running Benchmarks

# JVM
clojure -M:bench

# ClojureScript
clojure -M:cljs-bench && node bench-out/bench.js

# Babashka
bb bench

# Jank
jank run --module-path src:bench jank_bench_runner.jank