Using Malli to encode query parameters in Reitit-Frontend
A missing link between Malli and Reitit-Frontend

Our friends at Biotz reached out to us one dark winter evening. They enjoyed using Reitit-Frontend with Malli for their frontend routing needs, but found that they always needed the same piece of boilerplate code in every project. We were happy to help them by adding a feature to Reitit under our Commercial Open Source Support program.
What the developers at Biotz wanted was to generate URLs like
http://my-app/page?filter=active&since=d2025-01-02
... based on a Reitit route definition with Malli schemas:
["/page"
{:name ::item
:view item-page
:parameters {:query [:map
[:filter {:optional true} :keyword]
[:since {:optional true} ::instant]]
... and a map of the parameters:
{:filter :active
:since #inst "2025-01-02T00:00:00.000-00:00"}
The current behaviour of Reitit was to just directly convert the parameters to strings, without using the Malli schema for the parameters to guide the encoding. This was a problem because the parameters were then decoded by the Malli schema when the user navigated to the url.
Making the query parameters round-trip via Malli encoders & decoders was an obvious missing piece in Reitit, so our frontend expert Juho quickly whipped up a prototype. After some further feedback from Biotz, he added more features, and the result was released as part of Reitit 0.8.0-alpha1.
Here's how it works. Consider a simple Reitit-Frontend router:
(ns example
(:require [clojure.string :as str]
[reitit.frontend :as rf]
[reitit.coercion.malli :as rcm]
[reitit.frontend.easy :as rfe]))
;; malli schema for vector of keywords that gets encoded as
;; [:a :b :c] <=> "a_b_c"
(def vector-with-custom-encoding
[:vector
{:encode/string (fn [xs] (str/join "_" (map name xs)))
:decode/string (fn [s] (mapv keyword (str/split s #"_")))}
:keyword])
;; malli schema for a string that gets encoded as UPPER CASE
;; but decoded in lower case
(def uppercase-string
[:string {:encode/string (fn [s] (str/upper-case s))
:decode/string (fn [s] (str/lower-case s))}])
;; router, two paths, one with coercion and the other without
(def router
(rf/router
["/"
["no-coercion"
{:name ::no-coercion
:parameters {:query [:map
[:color uppercase-string]
[:animals vector-with-custom-encoding]]}}]
["with-coercion"
{:name ::with-coercion
:coercion rcm/coercion
:parameters {:query [:map
[:color uppercase-string]
[:animals vector-with-custom-encoding]]}}]]))
;; print the :name of the path and the paramerers when navigating
(defn on-navigate [match _history]
(prn :NAVIGATE (-> match :data :name) (-> match :parameters)))
(rfe/start! router on-navigate {})
When using reitit.frontend.easy/href
to generate a URL for a path
with no coercion, we get the old, default behaviour. Vectors get
transformed to repetitions of the same query parameter, and strings
get used directly:
(rfe/href ::no-coercion
{}
{:color "green" :animals [:fox :hedgehog]})
;; => "#/no-coercion?color=green&animals=fox&animals=hedgehog"
But now, with the new feature, we get the right encoding for parameters for the endpoint that uses coercion:
(rfe/href ::with-coercion
{}
{:color "green" :animals [:fox :hedgehog]})
;; => "#/with-coercion?color=GREEN&animals=fox_hedgehog"
Note how the color is in upper-case and the animals query parameter is used only once.
If we now change the URL in the browser to
#/with-coercion?color=GREEN&animals=fox_hedgehog
, we'll see our
on-navigate
function print the decoded parameters, in exactly the
same format as we gave them to the href
function:
:NAVIGATE :example/with-coercion
{:query {:color "green", :animals [:fox :hedgehog]}}
Thanks once again to Biotz for sponsoring this feature! See the docs for more info.