diff --git a/js/src/main/scala/cs214/webapp/client/Pages.scala b/js/src/main/scala/cs214/webapp/client/Pages.scala
index d70fb37c670bf4acefc9d4323a3019c100157a54..9603741e3c0c1dd4c81721325a4e1afbad05547b 100644
--- a/js/src/main/scala/cs214/webapp/client/Pages.scala
+++ b/js/src/main/scala/cs214/webapp/client/Pages.scala
@@ -1,7 +1,7 @@
 package cs214.webapp
 package client
 
-import java.net.{URLDecoder, URLEncoder}
+import java.net.URLEncoder
 import scala.concurrent.ExecutionContext.Implicits.global
 import org.scalajs.dom
 import org.scalajs.dom.{Element, KeyCode}
@@ -21,45 +21,9 @@ abstract class Page:
     target.replaceChildren(frag.render)
     target.setAttribute("class", classList)
 
-  private def path: Seq[String] =
-    this match
-      case HomePage =>
-        Nil
-      case InstanceCreationPage(appId) =>
-        List("app", appId)
-      case UIPage(appId, instanceId) =>
-        List("app", appId, instanceId)
-      case JoinPage(appId, instanceId, uiId) =>
-        List("app", appId, instanceId, uiId)
-      case AppPage(appId, instanceId, uiId, userId) =>
-        List("app", appId, instanceId, uiId, userId)
-      case _ =>
-        throw IllegalArgumentException(f"No path for $this!")
-
-  def url =
-    f"/${path.map(s => URLEncoder.encode(s, "UTF-8")).mkString("/")}"
-
 object Page:
-  /** Creates a component from a URL path
-    * @param appInfos
-    *   retrieves the app information given its id
-    */
-  def from(url: String): Page =
-    val components = url.stripPrefix("/").stripSuffix("/").split("/")
-    val decoded = components.map(c => URLDecoder.decode(c, "UTF-8")).toList
-    decoded match
-      case List("") =>
-        HomePage
-      case List("app", appId) =>
-        InstanceCreationPage(appId)
-      case List("app", appId, instanceId) =>
-        UIPage(appId, instanceId)
-      case List("app", appId, instanceId, uiId) =>
-        JoinPage(appId, instanceId, uiId)
-      case List("app", appId, instanceId, uiId, userId) =>
-        AppPage(appId, instanceId, uiId, userId)
-      case _ =>
-        throw IllegalArgumentException(f"Unknown url $url!")
+  @deprecated("Use WebClient.route")
+  def from(url: String): Page = WebClient.route(url)
 
 /** The initial home page which fetches the list of apps */
 object HomePage extends Page:
diff --git a/js/src/main/scala/cs214/webapp/client/Router.scala b/js/src/main/scala/cs214/webapp/client/Router.scala
new file mode 100644
index 0000000000000000000000000000000000000000..103b8174d4ead30cae0884a6252199f469f4833f
--- /dev/null
+++ b/js/src/main/scala/cs214/webapp/client/Router.scala
@@ -0,0 +1,46 @@
+package cs214.webapp
+package client
+
+import java.net.{URLDecoder, URLEncoder}
+
+abstract class Router:
+  protected def path(page: Page): Seq[String]
+  protected def page(path: Seq[String]): Page
+
+  def url(page: Page): String =
+    val fragments = path(page).map(s => URLEncoder.encode(s, "UTF-8"))
+    ("" +: fragments).mkString("/")
+
+  def route(url: String): Page =
+    val components = url.stripPrefix("/").stripSuffix("/").split("/")
+    val decoded = components.map(c => URLDecoder.decode(c, "UTF-8")).toList
+    page(decoded)
+
+object DefaultRouter extends Router:
+  def path(page: Page): Seq[String] = page match
+    case HomePage =>
+      List("")
+    case InstanceCreationPage(appId) =>
+      List("app", appId)
+    case UIPage(appId, instanceId) =>
+      List("app", appId, instanceId)
+    case JoinPage(appId, instanceId, uiId) =>
+      List("app", appId, instanceId, uiId)
+    case AppPage(appId, instanceId, uiId, userId) =>
+      List("app", appId, instanceId, uiId, userId)
+    case _ =>
+      throw IllegalArgumentException(f"No path for $this!")
+
+  def page(path: List[String]): Page = path match
+    case List("") =>
+      HomePage
+    case List("app", appId) =>
+      InstanceCreationPage(appId)
+    case List("app", appId, instanceId) =>
+      UIPage(appId, instanceId)
+    case List("app", appId, instanceId, uiId) =>
+      JoinPage(appId, instanceId, uiId)
+    case List("app", appId, instanceId, uiId, userId) =>
+      AppPage(appId, instanceId, uiId, userId)
+    case _ =>
+      throw IllegalArgumentException(f"Unknown url $url!")
diff --git a/js/src/main/scala/cs214/webapp/client/WebClient.scala b/js/src/main/scala/cs214/webapp/client/WebClient.scala
index d4ae99e10be0b304e732ea76a9d5eee7478a4cf2..6ec1ee235a8b6aa630ab29a868b74af23932e64d 100644
--- a/js/src/main/scala/cs214/webapp/client/WebClient.scala
+++ b/js/src/main/scala/cs214/webapp/client/WebClient.scala
@@ -8,7 +8,13 @@ import org.scalajs.dom
 object WebClient:
   private val appLibrary: mutable.Map[AppId, Seq[ClientApp]] = mutable.Map.empty
 
-  val getApps = (appId: AppId) => appLibrary.getOrElse(appId, Seq.empty[ClientApp])
+  private var router: Router = DefaultRouter
+  def setRouter(router: Router): Unit = this.router = router
+  def route(url: String): Page = router.route(url)
+  def url(page: Page): String = router.url(page)
+
+  def getApps(appId: AppId) =
+    appLibrary.getOrElse(appId, Seq.empty[ClientApp])
 
   def register(clientApp: ClientApp): Unit =
     println(s"Registered ${clientApp.appId}/${clientApp.uiId}'s UI")
@@ -20,14 +26,12 @@ object WebClient:
 
   def start(root: dom.Element): Unit =
     println(s"Registered apps: ${appLibrary.keys.toSeq.sorted}.")
-    try
-      Page.from(dom.document.location.pathname)
-        .renderInto(root)
+    try router.route(dom.document.location.pathname).renderInto(root)
     catch case t => crash(t)
 
   def navigateTo(page: Page, overwriteHistory: Boolean = false) =
-    if overwriteHistory then dom.window.location.replace(page.url)
-    else dom.window.location.assign(page.url)
+    if overwriteHistory then dom.window.location.replace(url(page))
+    else dom.window.location.assign(url(page))
 
   def crash(t: Throwable) =
     dom.document.querySelector("body").prepend: