diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..356385d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,32 @@
+# http://editorconfig.org
+
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+charset = utf-8
+end_of_line = lf
+
+[*.py]
+indent_size = 4
+max_line_length = 120
+
+[*.md]
+indent_size = 4
+
+[*.html]
+max_line_length = off
+
+[*.js]
+max_line_length = off
+
+[*.css]
+indent_size = 4
+max_line_length = off
+
+# Tests can violate line width restrictions in the interest of clarity.
+[**/test_*.py]
+max_line_length = off
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 0f26793..213f18a 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -12,67 +12,51 @@
 name: "CodeQL"
 
 on:
-  push:
-    branches: [ "main" ]
-  pull_request:
-    # The branches below must be a subset of the branches above
-    branches: [ "main" ]
-  schedule:
-    # Runs at 22:21 on Monday.
-    - cron: '21 22 * * 1'
+    push:
+        branches: ["main"]
+    pull_request:
+        # The branches below must be a subset of the branches above
+        branches: ["main"]
+    schedule:
+        # Runs at 22:21 on Monday.
+        - cron: "21 22 * * 1"
 
 jobs:
-  analyze:
-    name: Analyze
-    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
-    timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
-    permissions:
-      actions: read
-      contents: read
-      security-events: write
-
-    strategy:
-      fail-fast: false
-      matrix:
-        language: [ 'javascript', 'python' ]
-        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ]
-        # Use only 'java' to analyze code written in Java, Kotlin or both
-        # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
-        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
-
-    steps:
-    - name: Checkout repository
-      uses: actions/checkout@v3
-
-    # Initializes the CodeQL tools for scanning.
-    - name: Initialize CodeQL
-      uses: github/codeql-action/init@v2
-      with:
-        languages: ${{ matrix.language }}
-        # If you wish to specify custom queries, you can do so here or in a config file.
-        # By default, queries listed here will override any specified in a config file.
-        # Prefix the list here with "+" to use these queries and those in the config file.
-
-        # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
-        # queries: security-extended,security-and-quality
-
-
-    # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
-    # If this step fails, then you should remove it and run the build manually (see below)
-    - name: Autobuild
-      uses: github/codeql-action/autobuild@v2
-
-    # â„šī¸ Command-line programs to run using the OS shell.
-    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
-
-    #   If the Autobuild fails above, remove it and uncomment the following three lines.
-    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
-
-    # - run: |
-    #     echo "Run, Build Application using script"
-    #     ./location_of_script_within_repo/buildscript.sh
-
-    - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v2
-      with:
-        category: "/language:${{matrix.language}}"
+    analyze:
+        name: Analyze
+        runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+        timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
+        permissions:
+            actions: read
+            contents: read
+            security-events: write
+
+        strategy:
+            fail-fast: false
+            matrix:
+                language: ["javascript", "python"]
+                # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ]
+                # Use only 'java' to analyze code written in Java, Kotlin or both
+                # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
+                # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+        steps:
+            - name: Checkout repository
+              uses: actions/checkout@v4
+
+            # Initializes the CodeQL tools for scanning.
+            - name: Initialize CodeQL
+              uses: github/codeql-action/init@v3
+              with:
+                  languages: ${{ matrix.language }}
+                  # If you wish to specify custom queries, you can do so here or in a config file.
+                  # By default, queries listed here will override any specified in a config file.
+                  # Prefix the list here with "+" to use these queries and those in the config file.
+
+                  # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+                  # queries: security-extended,security-and-quality
+
+            - name: Perform CodeQL Analysis
+              uses: github/codeql-action/analyze@v3
+              with:
+                  category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml
index 6b1d4de..95d98da 100644
--- a/.github/workflows/publish-develop-docs.yml
+++ b/.github/workflows/publish-develop-docs.yml
@@ -7,10 +7,10 @@ jobs:
     deploy:
         runs-on: ubuntu-latest
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
               with:
                   fetch-depth: 0
-            - uses: actions/setup-python@v4
+            - uses: actions/setup-python@v5
               with:
                   python-version: 3.x
             - run: pip install -r requirements/build-docs.txt
diff --git a/.github/workflows/publish-py.yaml b/.github/workflows/publish-py.yaml
index 34ae5fa..d7437d7 100644
--- a/.github/workflows/publish-py.yaml
+++ b/.github/workflows/publish-py.yaml
@@ -11,9 +11,9 @@ jobs:
     publish-package:
         runs-on: ubuntu-latest
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
             - name: Set up Python
-              uses: actions/setup-python@v4
+              uses: actions/setup-python@v5
               with:
                   python-version: "3.x"
             - name: Install dependencies
diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml
index 6fc3233..a98e986 100644
--- a/.github/workflows/publish-release-docs.yml
+++ b/.github/workflows/publish-release-docs.yml
@@ -8,10 +8,10 @@ jobs:
     deploy:
         runs-on: ubuntu-latest
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
               with:
                   fetch-depth: 0
-            - uses: actions/setup-python@v4
+            - uses: actions/setup-python@v5
               with:
                   python-version: 3.x
             - run: pip install -r requirements/build-docs.txt
diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml
index d5f5052..7110bc4 100644
--- a/.github/workflows/test-docs.yml
+++ b/.github/workflows/test-docs.yml
@@ -1,37 +1,39 @@
 name: Test
 
 on:
-    push:
-        branches:
-            - main
-    pull_request:
-        branches:
-            - main
-    schedule:
-        - cron: "0 0 * * *"
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+  schedule:
+    - cron: "0 0 * * *"
 
 jobs:
-    docs:
-        runs-on: ubuntu-latest
-        steps:
-            - uses: actions/checkout@v3
-              with:
-                  fetch-depth: 0
-            - uses: actions/setup-python@v4
-              with:
-                  python-version: 3.x
-            - name: Check docs build
-              run: |
-                  pip install -r requirements/build-docs.txt
-                  linkcheckMarkdown docs/ -v -r
-                  linkcheckMarkdown README.md -v -r
-                  linkcheckMarkdown CHANGELOG.md -v -r
-                  cd docs
-                  mkdocs build --strict
-            - name: Check docs examples
-              run: |
-                  pip install -r requirements/check-types.txt
-                  pip install -r requirements/check-style.txt
-                  mypy --show-error-codes docs/examples/python/
-                  black docs/examples/python/ --check
-                  ruff check docs/examples/python/
+  docs:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+      - name: Install Python Dependencies
+        run: |
+          pip install -r requirements/build-docs.txt
+          pip install -r requirements/check-types.txt
+          pip install -r requirements/check-style.txt
+          pip install -e .
+      - name: Check docs build
+        run: |
+          linkcheckMarkdown docs/ -v -r
+          linkcheckMarkdown README.md -v -r
+          linkcheckMarkdown CHANGELOG.md -v -r
+          cd docs
+          mkdocs build --strict
+      - name: Check docs examples
+        run: |
+          mypy --show-error-codes docs/examples/python/
+          ruff check docs/examples/python/
diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml
index b5ae7d0..df93152 100644
--- a/.github/workflows/test-src.yaml
+++ b/.github/workflows/test-src.yaml
@@ -17,9 +17,9 @@ jobs:
             matrix:
                 python-version: ["3.9", "3.10", "3.11"]
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
             - name: Use Python ${{ matrix.python-version }}
-              uses: actions/setup-python@v4
+              uses: actions/setup-python@v5
               with:
                   python-version: ${{ matrix.python-version }}
             - name: Install Python Dependencies
@@ -29,9 +29,9 @@ jobs:
     coverage:
         runs-on: ubuntu-latest
         steps:
-            - uses: actions/checkout@v2
+            - uses: actions/checkout@v4
             - name: Use Latest Python
-              uses: actions/setup-python@v2
+              uses: actions/setup-python@v5
               with:
                   python-version: "3.10"
             - name: Install Python Dependencies
diff --git a/.gitignore b/.gitignore
index 9155bda..12271fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,7 @@ docs/site
 
 # --- JAVASCRIPT BUNDLES ---
 
-src/reactpy_router/bundle.js
+src/reactpy_router/static/bundle.js
 
 # --- PYTHON IGNORE FILES ----
 
@@ -108,7 +108,7 @@ celerybeat.pid
 
 # Environments
 .env
-.venv
+.venv*
 env/
 venv/
 ENV/
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..32ad81f
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,4 @@
+{
+  "proseWrap": "never",
+  "trailingComma": "all"
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c673a2f..7380865 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,7 +34,31 @@ Using the following categories, list your changes in this order:
 
 ## [Unreleased]
 
--   Nothing (yet)!
+### Changed
+
+-   Bump GitHub workflows
+-   Rename `use_query` to `use_search_params`.
+-   Rename `simple.router` to `browser_router`.
+-   Rename `SimpleResolver` to `StarletteResolver`.
+-   Rename `CONVERSION_TYPES` to `CONVERTERS`.
+-   Change "Match Any" syntax from a star `*` to `{name:any}`.
+-   Rewrite `reactpy_router.link` to be a server-side component.
+-   Simplified top-level exports within `reactpy_router`.
+
+### Added
+
+-   New error for ReactPy router elements being used outside router context.
+-   Configurable/inheritable `Resolver` base class.
+-   Add debug log message for when there are no router matches.
+-   Add slug as a supported type.
+
+### Fixed
+
+-   Fix bug where changing routes could cause render failure due to key identity.
+-   Fix bug where "Match Any" pattern wouldn't work when used in complex or nested paths.
+-   Fix bug where `link` elements could not have `@component` type children.
+-   Fix bug where the ReactPy would not detect the current URL after a reconnection.
+-   Fixed flakey tests on GitHub CI by adding click delays.
 
 ## [0.1.1] - 2023-12-13
 
diff --git a/MANIFEST.in b/MANIFEST.in
index bdca1f4..71f4855 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,2 @@
-include src/reactpy_router/bundle.js
+recursive-include src/reactpy_router/static *
 include src/reactpy_router/py.typed
diff --git a/docs/examples/python/basic-routing-more-routes.py b/docs/examples/python/basic-routing-more-routes.py
index 8ddbebb..32bb31e 100644
--- a/docs/examples/python/basic-routing-more-routes.py
+++ b/docs/examples/python/basic-routing-more-routes.py
@@ -1,14 +1,13 @@
 from reactpy import component, html, run
-
-from reactpy_router import route, simple
+from reactpy_router import browser_router, route
 
 
 @component
 def root():
-    return simple.router(
+    return browser_router(
         route("/", html.h1("Home Page 🏠")),
         route("/messages", html.h1("Messages đŸ’Ŧ")),
-        route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
+        route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
     )
 
 
diff --git a/docs/examples/python/basic-routing.py b/docs/examples/python/basic-routing.py
index 57b7a37..43c4e65 100644
--- a/docs/examples/python/basic-routing.py
+++ b/docs/examples/python/basic-routing.py
@@ -1,13 +1,12 @@
 from reactpy import component, html, run
-
-from reactpy_router import route, simple
+from reactpy_router import browser_router, route
 
 
 @component
 def root():
-    return simple.router(
+    return browser_router(
         route("/", html.h1("Home Page 🏠")),
-        route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
+        route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
     )
 
 
diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py
index f03a692..01ffb18 100644
--- a/docs/examples/python/nested-routes.py
+++ b/docs/examples/python/nested-routes.py
@@ -1,7 +1,8 @@
 from typing import TypedDict
 
 from reactpy import component, html, run
-from reactpy_router import link, route, simple
+
+from reactpy_router import browser_router, link, route
 
 message_data: list["MessageDataType"] = [
     {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"},
@@ -17,7 +18,7 @@
 
 @component
 def root():
-    return simple.router(
+    return browser_router(
         route("/", home()),
         route(
             "/messages",
@@ -26,7 +27,7 @@ def root():
             route("/with/Alice", messages_with("Alice")),
             route("/with/Alice-Bob", messages_with("Alice", "Bob")),
         ),
-        route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
+        route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
     )
 
 
@@ -34,39 +35,32 @@ def root():
 def home():
     return html.div(
         html.h1("Home Page 🏠"),
-        link("Messages", to="/messages"),
+        link({"to": "/messages"}, "Messages"),
     )
 
 
 @component
 def all_messages():
-    last_messages = {
-        ", ".join(msg["with"]): msg
-        for msg in sorted(message_data, key=lambda m: m["id"])
-    }
+    last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])}
+
+    messages = []
+    for msg in last_messages.values():
+        _link = link(
+            {"to": f"/messages/with/{'-'.join(msg['with'])}"},
+            f"Conversation with: {', '.join(msg['with'])}",
+        )
+        msg_from = f"{'' if msg['from'] is None else '🔴'} {msg['message']}"
+        messages.append(html.li({"key": msg["id"]}, html.p(_link), msg_from))
+
     return html.div(
         html.h1("All Messages đŸ’Ŧ"),
-        html.ul(
-            [
-                html.li(
-                    {"key": msg["id"]},
-                    html.p(
-                        link(
-                            f"Conversation with: {', '.join(msg['with'])}",
-                            to=f"/messages/with/{'-'.join(msg['with'])}",
-                        ),
-                    ),
-                    f"{'' if msg['from'] is None else '🔴'} {msg['message']}",
-                )
-                for msg in last_messages.values()
-            ]
-        ),
+        html.ul(messages),
     )
 
 
 @component
 def messages_with(*names):
-    messages = [msg for msg in message_data if set(msg["with"]) == names]
+    messages = [msg for msg in message_data if tuple(msg["with"]) == names]
     return html.div(
         html.h1(f"Messages with {', '.join(names)} đŸ’Ŧ"),
         html.ul(
diff --git a/docs/examples/python/route-links.py b/docs/examples/python/route-links.py
index f2be305..baf428c 100644
--- a/docs/examples/python/route-links.py
+++ b/docs/examples/python/route-links.py
@@ -1,14 +1,14 @@
 from reactpy import component, html, run
 
-from reactpy_router import link, route, simple
+from reactpy_router import browser_router, link, route
 
 
 @component
 def root():
-    return simple.router(
+    return browser_router(
         route("/", home()),
         route("/messages", html.h1("Messages đŸ’Ŧ")),
-        route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
+        route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
     )
 
 
@@ -16,7 +16,7 @@ def root():
 def home():
     return html.div(
         html.h1("Home Page 🏠"),
-        link("Messages", to="/messages"),
+        link({"to": "/messages"}, "Messages"),
     )
 
 
diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py
index 4fd30e2..a794742 100644
--- a/docs/examples/python/route-parameters.py
+++ b/docs/examples/python/route-parameters.py
@@ -1,8 +1,8 @@
 from typing import TypedDict
 
 from reactpy import component, html, run
-from reactpy_router import link, route, simple
-from reactpy_router.core import use_params
+
+from reactpy_router import browser_router, link, route, use_params
 
 message_data: list["MessageDataType"] = [
     {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"},
@@ -18,14 +18,14 @@
 
 @component
 def root():
-    return simple.router(
+    return browser_router(
         route("/", home()),
         route(
             "/messages",
             all_messages(),
             route("/with/{names}", messages_with()),  # note the path param
         ),
-        route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
+        route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")),
     )
 
 
@@ -33,40 +33,32 @@ def root():
 def home():
     return html.div(
         html.h1("Home Page 🏠"),
-        link("Messages", to="/messages"),
+        link({"to": "/messages"}, "Messages"),
     )
 
 
 @component
 def all_messages():
-    last_messages = {
-        ", ".join(msg["with"]): msg
-        for msg in sorted(message_data, key=lambda m: m["id"])
-    }
+    last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])}
+    messages = []
+    for msg in last_messages.values():
+        msg_hyperlink = link(
+            {"to": f"/messages/with/{'-'.join(msg['with'])}"},
+            f"Conversation with: {', '.join(msg['with'])}",
+        )
+        msg_from = f"{'' if msg['from'] is None else '🔴'} {msg['message']}"
+        messages.append(html.li({"key": msg["id"]}, html.p(msg_hyperlink), msg_from))
+
     return html.div(
         html.h1("All Messages đŸ’Ŧ"),
-        html.ul(
-            [
-                html.li(
-                    {"key": msg["id"]},
-                    html.p(
-                        link(
-                            f"Conversation with: {', '.join(msg['with'])}",
-                            to=f"/messages/with/{'-'.join(msg['with'])}",
-                        ),
-                    ),
-                    f"{'' if msg['from'] is None else '🔴'} {msg['message']}",
-                )
-                for msg in last_messages.values()
-            ]
-        ),
+        html.ul(messages),
     )
 
 
 @component
 def messages_with():
-    names = set(use_params()["names"].split("-"))  # and here we use the path param
-    messages = [msg for msg in message_data if set(msg["with"]) == names]
+    names = tuple(use_params()["names"].split("-"))  # and here we use the path param
+    messages = [msg for msg in message_data if tuple(msg["with"]) == names]
     return html.div(
         html.h1(f"Messages with {', '.join(names)} đŸ’Ŧ"),
         html.ul(
diff --git a/docs/examples/python/use-params.py b/docs/examples/python/use-params.py
index 7b1193a..93a4f07 100644
--- a/docs/examples/python/use-params.py
+++ b/docs/examples/python/use-params.py
@@ -1,23 +1,26 @@
-from reactpy import component, html
+from reactpy import component, html, run
 
-from reactpy_router import link, route, simple, use_params
+from reactpy_router import browser_router, link, route, use_params
 
 
 @component
 def user():
     params = use_params()
-    return html.h1(f"User {params['id']} 👤")
+    return html._(html.h1(f"User {params['id']} 👤"), html.p("Nothing (yet)."))
 
 
 @component
 def root():
-    return simple.router(
+    return browser_router(
         route(
             "/",
             html.div(
                 html.h1("Home Page 🏠"),
-                link("User 123", to="/user/123"),
+                link({"to": "/user/123"}, "User 123"),
             ),
         ),
         route("/user/{id:int}", user()),
     )
+
+
+run(root)
diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use-query.py
deleted file mode 100644
index a8678cc..0000000
--- a/docs/examples/python/use-query.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from reactpy import component, html
-
-from reactpy_router import link, route, simple, use_query
-
-
-@component
-def search():
-    query = use_query()
-    return html.h1(f"Search Results for {query['q'][0]} 🔍")
-
-
-@component
-def root():
-    return simple.router(
-        route(
-            "/",
-            html.div(
-                html.h1("Home Page 🏠"),
-                link("Search", to="/search?q=reactpy"),
-            ),
-        ),
-        route("/about", html.h1("About Page 📖")),
-    )
diff --git a/docs/examples/python/use-search-params.py b/docs/examples/python/use-search-params.py
new file mode 100644
index 0000000..faeba5e
--- /dev/null
+++ b/docs/examples/python/use-search-params.py
@@ -0,0 +1,26 @@
+from reactpy import component, html, run
+
+from reactpy_router import browser_router, link, route, use_search_params
+
+
+@component
+def search():
+    search_params = use_search_params()
+    return html._(html.h1(f"Search Results for {search_params['query'][0]} 🔍"), html.p("Nothing (yet)."))
+
+
+@component
+def root():
+    return browser_router(
+        route(
+            "/",
+            html.div(
+                html.h1("Home Page 🏠"),
+                link({"to": "/search?query=reactpy"}, "Search"),
+            ),
+        ),
+        route("/search", search()),
+    )
+
+
+run(root)
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index d93b302..5173834 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -1,147 +1,137 @@
 ---
 nav:
-    - Get Started: 
-        - Add ReactPy-Router to Your Project: index.md
-        - Your First Routed Application: learn/simple-application.md
-        - Advanced Topics:
-            - Routers, Routes, and Links: learn/routers-routes-and-links.md
-            - Hooks: learn/hooks.md
-            - Creating a Custom Router 🚧: learn/custom-router.md
-    - Reference:
-        - Core: reference/core.md
-        - Router: reference/router.md
-        - Types: reference/types.md
-    - About:
-        - Changelog: about/changelog.md
-        - Contributor Guide:
-            - Code: about/code.md
-            - Docs: about/docs.md
-        - Community:
-            - GitHub Discussions: https://github.com/reactive-python/reactpy-router/discussions
-            - Discord: https://discord.gg/uNb5P4hA9X
-            - Reddit: https://www.reddit.com/r/ReactPy/
-        - License: about/license.md
+  - Get Started:
+      - Add ReactPy-Router to Your Project: index.md
+      - Your First Routed Application: learn/your-first-app.md
+      - Advanced Topics:
+          - Routers, Routes, and Links: learn/routers-routes-and-links.md
+          - Hooks: learn/hooks.md
+          - Creating a Custom Router 🚧: learn/custom-router.md
+  - Reference:
+      - Router Components: reference/router.md
+      - Components: reference/components.md
+      - Hooks: reference/hooks.md
+      - Types: reference/types.md
+  - About:
+      - Changelog: about/changelog.md
+      - Contributor Guide:
+          - Code: about/code.md
+          - Docs: about/docs.md
+      - Community:
+          - GitHub Discussions: https://github.com/reactive-python/reactpy-router/discussions
+          - Discord: https://discord.gg/uNb5P4hA9X
+          - Reddit: https://www.reddit.com/r/ReactPy/
+      - License: about/license.md
 
 theme:
-    name: material
-    custom_dir: overrides
-    palette:
-        - media: "(prefers-color-scheme: dark)"
-          scheme: slate
-          toggle:
-              icon: material/white-balance-sunny
-              name: Switch to light mode
-          primary: red # We use red to indicate that something is unthemed
-          accent: red
-        - media: "(prefers-color-scheme: light)"
-          scheme: default
-          toggle:
-              icon: material/weather-night
-              name: Switch to dark mode
-          primary: white
-          accent: red
-    features:
-        - navigation.instant
-        - navigation.tabs
-        - navigation.tabs.sticky
-        - navigation.top
-        - content.code.copy
-        - search.highlight
-    icon:
-        repo: fontawesome/brands/github
-        admonition:
-            note: fontawesome/solid/note-sticky
-    logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg
-    favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg
+  name: material
+  custom_dir: overrides
+  palette:
+    - media: "(prefers-color-scheme: dark)"
+      scheme: slate
+      toggle:
+        icon: material/white-balance-sunny
+        name: Switch to light mode
+      primary: red # We use red to indicate that something is unthemed
+      accent: red
+    - media: "(prefers-color-scheme: light)"
+      scheme: default
+      toggle:
+        icon: material/weather-night
+        name: Switch to dark mode
+      primary: white
+      accent: red
+  features:
+    - navigation.instant
+    - navigation.tabs
+    - navigation.tabs.sticky
+    - navigation.top
+    - content.code.copy
+    - search.highlight
+  icon:
+    repo: fontawesome/brands/github
+    admonition:
+      note: fontawesome/solid/note-sticky
+  logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg
+  favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg
 
 markdown_extensions:
-    - toc:
-          permalink: true
-    - pymdownx.emoji:
-          emoji_index: !!python/name:material.extensions.emoji.twemoji
-          emoji_generator: !!python/name:material.extensions.emoji.to_svg
-    - pymdownx.tabbed:
-          alternate_style: true
-    - pymdownx.highlight:
-          linenums: true
-    - pymdownx.superfences
-    - pymdownx.details
-    - pymdownx.inlinehilite
-    - admonition
-    - attr_list
-    - md_in_html
-    - pymdownx.keys
+  - toc:
+      permalink: true
+  - pymdownx.emoji:
+      emoji_index: !!python/name:material.extensions.emoji.twemoji
+      emoji_generator: !!python/name:material.extensions.emoji.to_svg
+  - pymdownx.tabbed:
+      alternate_style: true
+  - pymdownx.highlight:
+      linenums: true
+  - pymdownx.superfences
+  - pymdownx.details
+  - pymdownx.inlinehilite
+  - admonition
+  - attr_list
+  - md_in_html
+  - pymdownx.keys
 
 plugins:
-    - search
-    - include-markdown
-    - git-authors
-    - minify:
-          minify_html: true
-          minify_js: true
-          minify_css: true
-          cache_safe: true
-    - git-revision-date-localized:
-          fallback_to_build_date: true
-    - spellcheck:
-          known_words: dictionary.txt
-          allow_unicode: no
-          ignore_code: yes
-          skip_files:
-            - "index.md"
-            - "reference\\core.md"
-            - "reference/core.md"
-            - "reference\\types.md"
-            - "reference/types.md"
-    - mkdocstrings:
-          default_handler: python
-          handlers:
-              python:
-                  paths: ["../"]
-                  import:
-                      - https://reactpy.dev/docs/objects.inv
-                      - https://installer.readthedocs.io/en/stable/objects.inv
-
+  - search
+  - include-markdown
+  - git-authors
+  - minify:
+      minify_html: true
+      minify_js: true
+      minify_css: true
+      cache_safe: true
+  - git-revision-date-localized:
+      fallback_to_build_date: true
+  - spellcheck:
+      known_words: dictionary.txt
+      allow_unicode: no
+  - mkdocstrings:
+      default_handler: python
+      handlers:
+        python:
+          paths: ["../"]
+          import:
+            - https://reactpy.dev/docs/objects.inv
+            - https://installer.readthedocs.io/en/stable/objects.inv
+          options:
+            show_bases: false
+            show_root_members_full_path: true
 extra:
-    generator: false
-    version:
-        provider: mike
-    analytics:
-        provider: google
-        property: G-XRLQYZBG00
+  generator: false
+  version:
+    provider: mike
+  analytics:
+    provider: google
+    property: G-XRLQYZBG00
 
 extra_javascript:
-    - assets/js/main.js
+  - assets/js/main.js
 
 extra_css:
-    - assets/css/main.css
-    - assets/css/button.css
-    - assets/css/admonition.css
-    - assets/css/banner.css
-    - assets/css/sidebar.css
-    - assets/css/navbar.css
-    - assets/css/table-of-contents.css
-    - assets/css/code.css
-    - assets/css/footer.css
-    - assets/css/home.css
+  - assets/css/main.css
+  - assets/css/button.css
+  - assets/css/admonition.css
+  - assets/css/banner.css
+  - assets/css/sidebar.css
+  - assets/css/navbar.css
+  - assets/css/table-of-contents.css
+  - assets/css/code.css
+  - assets/css/footer.css
+  - assets/css/home.css
 
 watch:
-    - "../docs"
-    - ../README.md
-    - ../CHANGELOG.md
-    - ../LICENSE.md
-    - "../src"
+  - "../docs"
+  - ../README.md
+  - ../CHANGELOG.md
+  - ../LICENSE.md
+  - "../src"
 
 site_name: ReactPy Router
 site_author: Archmonger
 site_description: It's React-Router, but in Python.
-copyright: '©
-<div id="year"></div>
-<script> document.getElementById("year").innerHTML = new Date().getFullYear(); </script>
-Reactive Python and affiliates.
-<div class="legal-footer-right">
-This project has no affiliation to ReactJS or Meta Platforms, Inc.
-</div>'
+copyright: '&copy;<div id="year"> </div> <script> document.getElementById("year").innerHTML = new Date().getFullYear(); </script>Reactive Python and affiliates.<div class="legal-footer-right">This project has no affiliation to ReactJS or Meta Platforms, Inc.</div>'
 repo_url: https://github.com/reactive-python/reactpy-router
 site_url: https://reactive-python.github.io/reactpy-router
 repo_name: ReactPy Router (GitHub)
diff --git a/docs/overrides/home-code-examples/add-interactivity-demo.html b/docs/overrides/home-code-examples/add-interactivity-demo.html
deleted file mode 100644
index 48ac19a..0000000
--- a/docs/overrides/home-code-examples/add-interactivity-demo.html
+++ /dev/null
@@ -1,172 +0,0 @@
-<div class="demo pop-right">
-    <div class="white-bg">
-
-        <div class="browser-navbar">
-            <div class="browser-nav-url">
-                <svg class="text-tertiary me-1 opacity-60" width="12" height="12" viewBox="0 0 44 44" fill="none"
-                    xmlns="http://www.w3.org/2000/svg">
-                    <path fill-rule="evenodd" clip-rule="evenodd"
-                        d="M22 4C17.0294 4 13 8.0294 13 13V16H12.3103C10.5296 16 8.8601 16.8343 8.2855 18.5198C7.6489 20.387 7 23.4148 7 28C7 32.5852 7.6489 35.613 8.2855 37.4802C8.8601 39.1657 10.5296 40 12.3102 40H31.6897C33.4704 40 35.1399 39.1657 35.7145 37.4802C36.3511 35.613 37 32.5852 37 28C37 23.4148 36.3511 20.387 35.7145 18.5198C35.1399 16.8343 33.4704 16 31.6897 16H31V13C31 8.0294 26.9706 4 22 4ZM25 16V13C25 11.3431 23.6569 10 22 10C20.3431 10 19 11.3431 19 13V16H25Z"
-                        fill="currentColor"></path>
-                </svg>
-                example.com/videos.html
-            </div>
-        </div>
-
-        <div class="browser-viewport">
-            <div class="search-header">
-                <h1>Searchable Videos</h1>
-                <p>Type a search query below.</p>
-                <div class="search-bar">
-                    <svg width="1em" height="1em" viewBox="0 0 20 20" class="text-gray-30 w-4">
-                        <path
-                            d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
-                            stroke="currentColor" fill="none" stroke-width="2" fill-rule="evenodd"
-                            stroke-linecap="round" stroke-linejoin="round"></path>
-                    </svg>
-                    <input type="text" placeholder="Search">
-                </div>
-            </div>
-
-            <h2>5 Videos</h2>
-
-            <div class="vid-row">
-                <div class="vid-thumbnail">
-                    <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path fill-rule="evenodd" clip-rule="evenodd"
-                            d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                            fill="rgb(123 123 123 / 50%)"></path>
-                    </svg>
-                </div>
-                <div class="vid-text">
-                    <h3>ReactPy: The Documentary</h3>
-                    <p>From web library to taco delivery service</p>
-                </div>
-                <button class="like-btn">
-                    <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path
-                            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                            fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                    </svg>
-                </button>
-            </div>
-
-            <div class="vid-row">
-                <div class="vid-thumbnail">
-                    <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path fill-rule="evenodd" clip-rule="evenodd"
-                            d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                            fill="rgb(123 123 123 / 50%)"></path>
-                    </svg>
-                </div>
-                <div class="vid-text">
-                    <h3>Code using Worst Practices</h3>
-                    <p>Harriet Potter (2013)</p>
-                </div>
-                <button class="like-btn">
-                    <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path
-                            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                            fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                    </svg>
-                </button>
-            </div>
-
-            <div class="vid-row">
-                <div class="vid-thumbnail">
-                    <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path fill-rule="evenodd" clip-rule="evenodd"
-                            d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                            fill="rgb(123 123 123 / 50%)"></path>
-                    </svg>
-                </div>
-                <div class="vid-text">
-                    <h3>Introducing ReactPy Foriegn</h3>
-                    <p>Tim Cooker (2015)</p>
-                </div>
-                <button class="like-btn">
-                    <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path
-                            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                            fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                    </svg>
-                </button>
-            </div>
-
-            <div class="vid-row">
-                <div class="vid-thumbnail">
-                    <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path fill-rule="evenodd" clip-rule="evenodd"
-                            d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                            fill="rgb(123 123 123 / 50%)"></path>
-                    </svg>
-                </div>
-                <div class="vid-text">
-                    <h3>Introducing ReactPy Cooks</h3>
-                    <p>Soap Boat and Dinosaur Dan (2018)</p>
-                </div>
-                <button class="like-btn">
-                    <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path
-                            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                            fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                    </svg>
-                </button>
-            </div>
-
-            <div class="vid-row">
-                <div class="vid-thumbnail">
-                    <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path fill-rule="evenodd" clip-rule="evenodd"
-                            d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                            fill="rgb(123 123 123 / 50%)"></path>
-                    </svg>
-                </div>
-                <div class="vid-text">
-                    <h3>Introducing Quantum Components</h3>
-                    <p>Isaac Asimov and Lauren-kun (2020)</p>
-                </div>
-                <button class="like-btn">
-                    <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                        <path
-                            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                            fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                    </svg>
-                </button>
-            </div>
-            <p class="no-match"></p>
-        </div>
-
-        <script>
-            document
-                .querySelector(".search-bar input")
-                .addEventListener("keyup", function () {
-                    let titles = document.querySelectorAll(".browser-viewport .vid-text");
-                    let search = this.value.toLowerCase();
-                    let numVids = 0;
-                    for (let i = 0; i < titles.length; i++) {
-                        let title =
-                            titles[i].querySelector("h3").innerText.toLowerCase() +
-                            titles[i].querySelector("p").innerText.toLowerCase();
-                        if (search.length == 0) {
-                            titles[i].parentElement.style.display = "";
-                            numVids++;
-                        } else if (title.indexOf(search) > -1) {
-                            titles[i].parentElement.style.display = "";
-                            numVids++;
-                        } else {
-                            titles[i].parentElement.style.display = "none";
-                        }
-                    }
-                    document.querySelector(".browser-viewport h2").innerText =
-                        numVids + " Videos";
-
-                    if (search && numVids == 0) {
-                        document.querySelector(".browser-viewport .no-match").innerText = `No matches for “${search}”`;
-                    } else {
-                        document.querySelector(".browser-viewport .no-match").innerText = "";
-                    }
-                });
-        </script>
-    </div>
-</div>
diff --git a/docs/overrides/home-code-examples/add-interactivity.py b/docs/overrides/home-code-examples/add-interactivity.py
deleted file mode 100644
index 9097644..0000000
--- a/docs/overrides/home-code-examples/add-interactivity.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from reactpy import component, html, use_state
-
-
-def filter_videos(videos, search_text):
-    return None
-
-
-def search_input(dictionary, value):
-    return None
-
-
-def video_list(videos, empty_heading):
-    return None
-
-
-@component
-def searchable_video_list(videos):
-    search_text, set_search_text = use_state("")
-    found_videos = filter_videos(videos, search_text)
-
-    return html._(
-        search_input(
-            {"on_change": lambda new_text: set_search_text(new_text)},
-            value=search_text,
-        ),
-        video_list(
-            videos=found_videos,
-            empty_heading=f"No matches for “{search_text}”",
-        ),
-    )
diff --git a/docs/overrides/home-code-examples/code-block.html b/docs/overrides/home-code-examples/code-block.html
deleted file mode 100644
index c1f14e5..0000000
--- a/docs/overrides/home-code-examples/code-block.html
+++ /dev/null
@@ -1,7 +0,0 @@
-<div class="tabbed-set tabbed-alternate {{ class }}">
-	<input checked="checked" type="radio" />
-	<div class="tabbed-labels"><label>app.py</label></div>
-	<div class="tabbed-content">
-		<img src="assets/img/{{ image }}">
-	</div>
-</div>
diff --git a/docs/overrides/home-code-examples/create-user-interfaces-demo.html b/docs/overrides/home-code-examples/create-user-interfaces-demo.html
deleted file mode 100644
index 9a684d3..0000000
--- a/docs/overrides/home-code-examples/create-user-interfaces-demo.html
+++ /dev/null
@@ -1,24 +0,0 @@
-<div class="demo">
-    <div class="white-bg">
-        <div class="vid-row">
-            <div class="vid-thumbnail">
-                <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path fill-rule="evenodd" clip-rule="evenodd"
-                        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                        fill="rgb(123 123 123 / 50%)"></path>
-                </svg>
-            </div>
-            <div class="vid-text">
-                <h3>My video</h3>
-                <p>Video description</p>
-            </div>
-            <button class="like-btn">
-                <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path
-                        d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                        fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                </svg>
-            </button>
-        </div>
-    </div>
-</div>
diff --git a/docs/overrides/home-code-examples/create-user-interfaces.py b/docs/overrides/home-code-examples/create-user-interfaces.py
deleted file mode 100644
index 37776ab..0000000
--- a/docs/overrides/home-code-examples/create-user-interfaces.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from reactpy import component, html
-
-
-def thumbnail(video):
-    return None
-
-
-def like_button(video):
-    return None
-
-
-@component
-def video(video):
-    return html.div(
-        thumbnail(video),
-        html.a(
-            {"href": video.url},
-            html.h3(video.title),
-            html.p(video.description),
-        ),
-        like_button(video),
-    )
diff --git a/docs/overrides/home-code-examples/write-components-with-python-demo.html b/docs/overrides/home-code-examples/write-components-with-python-demo.html
deleted file mode 100644
index 203287c..0000000
--- a/docs/overrides/home-code-examples/write-components-with-python-demo.html
+++ /dev/null
@@ -1,65 +0,0 @@
-<div class="demo pop-right">
-    <div class="white-bg">
-        <h2>3 Videos</h2>
-        <div class="vid-row">
-            <div class="vid-thumbnail">
-                <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path fill-rule="evenodd" clip-rule="evenodd"
-                        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                        fill="rgb(123 123 123 / 50%)"></path>
-                </svg>
-            </div>
-            <div class="vid-text">
-                <h3>First video</h3>
-                <p>Video description</p>
-            </div>
-            <button class="like-btn">
-                <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path
-                        d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                        fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                </svg>
-            </button>
-        </div>
-        <div class="vid-row">
-            <div class="vid-thumbnail">
-                <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path fill-rule="evenodd" clip-rule="evenodd"
-                        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                        fill="rgb(123 123 123 / 50%)"></path>
-                </svg>
-            </div>
-            <div class="vid-text">
-                <h3>Second video</h3>
-                <p>Video description</p>
-            </div>
-            <button class="like-btn">
-                <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path
-                        d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                        fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                </svg>
-            </button>
-        </div>
-        <div class="vid-row">
-            <div class="vid-thumbnail">
-                <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path fill-rule="evenodd" clip-rule="evenodd"
-                        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
-                        fill="rgb(123 123 123 / 50%)"></path>
-                </svg>
-            </div>
-            <div class="vid-text">
-                <h3>Third video</h3>
-                <p>Video description</p>
-            </div>
-            <button class="like-btn">
-                <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                    <path
-                        d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
-                        fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path>
-                </svg>
-            </button>
-        </div>
-    </div>
-</div>
diff --git a/docs/overrides/home-code-examples/write-components-with-python.py b/docs/overrides/home-code-examples/write-components-with-python.py
deleted file mode 100644
index 6af43ba..0000000
--- a/docs/overrides/home-code-examples/write-components-with-python.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from reactpy import component, html
-
-
-@component
-def video_list(videos, empty_heading):
-    count = len(videos)
-    heading = empty_heading
-    if count > 0:
-        noun = "Videos" if count > 1 else "Video"
-        heading = f"{count} {noun}"
-
-    return html.section(
-        html.h2(heading),
-        [video(video) for video in videos],
-    )
diff --git a/docs/src/about/code.md b/docs/src/about/code.md
index 5d73042..0eda9ee 100644
--- a/docs/src/about/code.md
+++ b/docs/src/about/code.md
@@ -40,12 +40,10 @@ By running the command below you can run the full test suite:
 nox -t test
 ```
 
-Or, if you want to run the tests in the foreground with a visible browser window, run:
-
-<!-- TODO: Change `headed` to `headless` -->
+Or, if you want to run the tests in the background run:
 
 ```bash linenums="0"
-nox -t test -- --headed
+nox -t test -- --headless
 ```
 
 ## Creating a pull request
diff --git a/docs/src/learn/hooks.md b/docs/src/learn/hooks.md
index 3479ffc..7ed3821 100644
--- a/docs/src/learn/hooks.md
+++ b/docs/src/learn/hooks.md
@@ -6,19 +6,19 @@ Several pre-fabricated hooks are provided to help integrate with routing feature
 
 ---
 
-## Use Query
+## Use Search Parameters
 
-The [`use_query`][src.reactpy_router.use_query] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings.
+The [`use_search_params`][reactpy_router.use_search_params] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings.
 
 === "components.py"
 
     ```python
-    {% include "../../examples/python/use-query.py" %}
+    {% include "../../examples/python/use-search-params.py" %}
     ```
 
 ## Use Parameters
 
-The [`use_params`][src.reactpy_router.use_params] hook can be used to access route parameters from the current location. It returns a dictionary of route parameters, where each value is mapped to a value that matches the type specified in the route path.
+The [`use_params`][reactpy_router.use_params] hook can be used to access route parameters from the current location. It returns a dictionary of route parameters, where each value is mapped to a value that matches the type specified in the route path.
 
 === "components.py"
 
diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md
index af62578..f0abfc1 100644
--- a/docs/src/learn/routers-routes-and-links.md
+++ b/docs/src/learn/routers-routes-and-links.md
@@ -4,14 +4,14 @@ We include built-in components that automatically handle routing, which enable S
 
 ## Routers and Routes
 
-The [`simple.router`][src.reactpy_router.simple.router] component is one possible implementation of a [Router][src.reactpy_router.types.Router]. Routers takes a series of [route][src.reactpy_router.route] objects as positional arguments and render whatever element matches the current location.
+The [`browser_router`][reactpy_router.browser_router] component is one possible implementation of a [Router][reactpy_router.types.Router]. Routers takes a series of [route][reactpy_router.route] objects as positional arguments and render whatever element matches the current location.
 
 !!! abstract "Note"
 
     The current location is determined based on the browser's current URL and can be found
     by checking the [`use_location`][reactpy.backend.hooks.use_location] hook.
 
-Here's a basic example showing how to use `#!python simple.router` with two routes.
+Here's a basic example showing how to use `#!python browser_router` with two routes.
 
 === "components.py"
 
@@ -19,11 +19,11 @@ Here's a basic example showing how to use `#!python simple.router` with two rout
     {% include "../../examples/python/basic-routing.py" %}
     ```
 
-Here we'll note some special syntax in the route path for the second route. The `#!python "*"` is a wildcard that will match any path. This is useful for creating a "404" page that will be shown when no other route matches.
+Here we'll note some special syntax in the route path for the second route. The `#!python "any"` type is a wildcard that will match any path. This is useful for creating a default page or error page such as "404 NOT FOUND".
 
-### Simple Router
+### Browser Router
 
-The syntax for declaring routes with the [simple.router][src.reactpy_router.simple.router] is very similar to the syntax used by [`starlette`](https://www.starlette.io/routing/) (a popular Python web framework). As such route parameters are declared using the following syntax:
+The syntax for declaring routes with the [`browser_router`][reactpy_router.browser_router] is very similar to the syntax used by [`starlette`](https://www.starlette.io/routing/) (a popular Python web framework). As such route parameters are declared using the following syntax:
 
 ```python linenums="0"
 /my/route/{param}
@@ -38,7 +38,9 @@ In this case, `#!python param` is the name of the route parameter and the option
 | `#!python int` | `#!python \d+` |
 | `#!python float` | `#!python \d+(\.\d+)?` |
 | `#!python uuid` | `#!python [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` |
+| `#!python slug` | `#!python [-a-zA-Z0-9_]+` |
 | `#!python path` | `#!python .+` |
+| `#!python any` | `#!python .*` |
 
 So in practice these each might look like:
 
@@ -50,15 +52,15 @@ So in practice these each might look like:
 /my/route/{param:path}
 ```
 
-Any route parameters collected from the current location then be accessed using the [`use_params`](#using-parameters) hook.
+Any route parameters collected from the current location then be accessed using the [`use_params`](hooks.md#use-parameters) hook.
 
 !!! warning "Pitfall"
 
-    While it is possible to use route parameters to capture values from query strings (such as `#!python /my/route/?foo={bar}`), this is not recommended. Instead, you should use the [`use_query`][src.reactpy_router.use_query] hook to access query string values.
+    While it is possible to use route parameters to capture values from query strings (such as `#!python /my/route/?foo={bar}`), this is not recommended. Instead, you should use the [`use_search_params`][reactpy_router.use_search_params] hook to access query string values.
 
 ## Route Links
 
-Links between routes should be created using the [link][src.reactpy_router.link] component. This will allow ReactPy to handle the transition between routes and avoid a page reload.
+Links between routes should be created using the [link][reactpy_router.link] component. This will allow ReactPy to handle the transition between routes and avoid a page reload.
 
 === "components.py"
 
diff --git a/docs/src/learn/simple-application.md b/docs/src/learn/your-first-app.md
similarity index 93%
rename from docs/src/learn/simple-application.md
rename to docs/src/learn/your-first-app.md
index 8f2a5b5..4b67677 100644
--- a/docs/src/learn/simple-application.md
+++ b/docs/src/learn/your-first-app.md
@@ -1,6 +1,6 @@
 <p class="intro" markdown>
 
-Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.simple.router`][src.reactpy_router.simple.router].
+Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.browser_router`][reactpy_router.browser_router].
 
 </p>
 
@@ -39,7 +39,7 @@ The first step is to create a basic router that will display the home page when
     {% include "../../examples/python/basic-routing.py" %}
     ```
 
-When navigating to [`http://127.0.0.1:8000``](http://127.0.0.1:8000) you should see `Home Page 🏠`. However, if you go to any other route you will instead see `Missing Link 🔗‍đŸ’Ĩ`.
+When navigating to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) you should see `Home Page 🏠`. However, if you go to any other route you will instead see `Missing Link 🔗‍đŸ’Ĩ`.
 
 With this foundation you can start adding more routes.
 
diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md
new file mode 100644
index 0000000..f1cc570
--- /dev/null
+++ b/docs/src/reference/components.md
@@ -0,0 +1,4 @@
+::: reactpy_router
+
+    options:
+        members: ["route", "link"]
diff --git a/docs/src/reference/core.md b/docs/src/reference/core.md
deleted file mode 100644
index 26cf9e5..0000000
--- a/docs/src/reference/core.md
+++ /dev/null
@@ -1 +0,0 @@
-::: src.reactpy_router.core
diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md
new file mode 100644
index 0000000..d3cfa18
--- /dev/null
+++ b/docs/src/reference/hooks.md
@@ -0,0 +1,4 @@
+::: reactpy_router
+
+    options:
+        members: ["use_params", "use_search_params"]
diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md
index 2fcea59..5700cf5 100644
--- a/docs/src/reference/router.md
+++ b/docs/src/reference/router.md
@@ -1 +1,4 @@
-::: src.reactpy_router.simple
+::: reactpy_router
+
+    options:
+        members: ["browser_router"]
diff --git a/docs/src/reference/types.md b/docs/src/reference/types.md
index 0482432..204bee7 100644
--- a/docs/src/reference/types.md
+++ b/docs/src/reference/types.md
@@ -1 +1 @@
-::: src.reactpy_router.types
+::: reactpy_router.types
diff --git a/noxfile.py b/noxfile.py
index 1eebea2..6376072 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -33,7 +33,6 @@ def test_types(session: Session) -> None:
 @session(tags=["test"])
 def test_style(session: Session) -> None:
     install_requirements_file(session, "check-style")
-    session.run("black", ".", "--check")
     session.run("ruff", "check", ".")
 
 
diff --git a/pyproject.toml b/pyproject.toml
index 763f3a0..d6a0110 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,11 +9,9 @@ warn_redundant_casts = true
 warn_unused_ignores = true
 check_untyped_defs = true
 
-[tool.ruff.isort]
-known-first-party = ["src", "tests"]
-
 [tool.ruff]
-ignore = ["E501"]
+lint.ignore = ["E501"]
+lint.isort.known-first-party = ["src", "tests"]
 extend-exclude = [".venv/*", ".eggs/*", ".nox/*", "build/*"]
 line-length = 120
 
diff --git a/requirements/check-style.txt b/requirements/check-style.txt
index e4f6562..af3ee57 100644
--- a/requirements/check-style.txt
+++ b/requirements/check-style.txt
@@ -1,2 +1 @@
-black >=23,<24
 ruff
diff --git a/src/js/package-lock.json b/src/js/package-lock.json
index db77c4d..c98d3c6 100644
--- a/src/js/package-lock.json
+++ b/src/js/package-lock.json
@@ -6,7 +6,6 @@
     "": {
       "name": "reactpy-router",
       "dependencies": {
-        "htm": "^3.0.4",
         "react": "^17.0.1",
         "react-dom": "^17.0.1"
       },
@@ -1098,11 +1097,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/htm": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz",
-      "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q=="
-    },
     "node_modules/ignore": {
       "version": "5.2.4",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@@ -3126,11 +3120,6 @@
         "has-symbols": "^1.0.2"
       }
     },
-    "htm": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz",
-      "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q=="
-    },
     "ignore": {
       "version": "5.2.4",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
diff --git a/src/js/package.json b/src/js/package.json
index 9baef8c..d4ac92c 100644
--- a/src/js/package.json
+++ b/src/js/package.json
@@ -18,16 +18,15 @@
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "devDependencies": {
-    "prettier": "^2.2.1",
     "eslint": "^8.38.0",
     "eslint-plugin-react": "^7.32.2",
+    "prettier": "^2.2.1",
     "rollup": "^2.35.1",
     "rollup-plugin-commonjs": "^10.1.0",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-replace": "^2.2.0"
   },
   "dependencies": {
-    "htm": "^3.0.4",
     "react": "^17.0.1",
     "react-dom": "^17.0.1"
   }
diff --git a/src/js/rollup.config.js b/src/js/rollup.config.js
index ab1d0b1..396fd87 100644
--- a/src/js/rollup.config.js
+++ b/src/js/rollup.config.js
@@ -5,7 +5,7 @@ import replace from "rollup-plugin-replace";
 export default {
   input: "src/index.js",
   output: {
-    file: "../reactpy_router/bundle.js",
+    file: "../reactpy_router/static/bundle.js",
     format: "esm",
   },
   plugins: [
diff --git a/src/js/src/index.js b/src/js/src/index.js
index 1f43092..8ead7eb 100644
--- a/src/js/src/index.js
+++ b/src/js/src/index.js
@@ -1,8 +1,5 @@
 import React from "react";
 import ReactDOM from "react-dom";
-import htm from "htm";
-
-const html = htm.bind(React.createElement);
 
 export function bind(node) {
   return {
@@ -15,30 +12,69 @@ export function bind(node) {
   };
 }
 
-export function History({ onChange }) {
-  // capture changes to the browser's history
+export function History({ onHistoryChange }) {
+  // Capture browser "history go back" action and tell the server about it
+  // Note: Browsers do not allow us to detect "history go forward" actions.
   React.useEffect(() => {
+    // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback.
     const listener = () => {
-      onChange({
+      onHistoryChange({
         pathname: window.location.pathname,
         search: window.location.search,
       });
     };
+
+    // Register the event listener
     window.addEventListener("popstate", listener);
+
+    // Delete the event listener when the component is unmounted
     return () => window.removeEventListener("popstate", listener);
   });
-  return null;
-}
 
-export function Link({ to, onClick, children, ...props }) {
-  const handleClick = (event) => {
-    event.preventDefault();
-    window.history.pushState({}, to, new URL(to, window.location));
-    onClick({
+  // Tell the server about the URL during the initial page load
+  // FIXME: This currently runs every time any component is mounted due to a ReactPy core rendering bug.
+  // https://github.com/reactive-python/reactpy/pull/1224
+  React.useEffect(() => {
+    onHistoryChange({
       pathname: window.location.pathname,
       search: window.location.search,
     });
-  };
+    return () => {};
+  }, []);
+  return null;
+}
 
-  return html`<a href=${to} onClick=${handleClick} ...${props}>${children}</a>`;
+// FIXME: The Link component is unused due to a ReactPy core rendering bug
+// which causes duplicate rendering (and thus duplicate event listeners).
+// https://github.com/reactive-python/reactpy/pull/1224
+export function Link({ onClick, linkClass }) {
+  // This component is not the actual anchor link.
+  // It is an event listener for the link component created by ReactPy.
+  React.useEffect(() => {
+    // Event function that will tell the server about clicks
+    const handleClick = (event) => {
+      event.preventDefault();
+      let to = event.target.getAttribute("href");
+      window.history.pushState({}, to, new URL(to, window.location));
+      onClick({
+        pathname: window.location.pathname,
+        search: window.location.search,
+      });
+    };
+
+    // Register the event listener
+    let link = document.querySelector(`.${linkClass}`);
+    if (link) {
+      link.addEventListener("click", handleClick);
+    }
+
+    // Delete the event listener when the component is unmounted
+    return () => {
+      let link = document.querySelector(`.${linkClass}`);
+      if (link) {
+        link.removeEventListener("click", handleClick);
+      }
+    };
+  });
+  return null;
 }
diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py
index cb2fcbc..fa2781f 100644
--- a/src/reactpy_router/__init__.py
+++ b/src/reactpy_router/__init__.py
@@ -1,20 +1,16 @@
 # the version is statically loaded by setup.py
 __version__ = "0.1.1"
 
-from . import simple
-from .core import create_router, link, route, router_component, use_params, use_query
-from .types import Route, RouteCompiler, RouteResolver
+
+from .components import link, route
+from .hooks import use_params, use_search_params
+from .routers import browser_router, create_router
 
 __all__ = (
     "create_router",
     "link",
     "route",
-    "route",
-    "Route",
-    "RouteCompiler",
-    "router_component",
-    "RouteResolver",
-    "simple",
+    "browser_router",
     "use_params",
-    "use_query",
+    "use_search_params",
 )
diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py
new file mode 100644
index 0000000..3008065
--- /dev/null
+++ b/src/reactpy_router/components.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+from urllib.parse import urljoin
+from uuid import uuid4
+
+from reactpy import component, event, html, use_connection
+from reactpy.backend.types import Location
+from reactpy.core.component import Component
+from reactpy.core.types import VdomDict
+from reactpy.web.module import export, module_from_file
+
+from reactpy_router.hooks import _use_route_state
+from reactpy_router.types import Route
+
+History = export(
+    module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"),
+    ("History"),
+)
+"""Client-side portion of history handling"""
+
+Link = export(
+    module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"),
+    ("Link"),
+)
+"""Client-side portion of link handling"""
+
+link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8")
+
+
+def link(attributes: dict[str, Any], *children: Any) -> Component:
+    """Create a link with the given attributes and children."""
+    return _link(attributes, *children)
+
+
+@component
+def _link(attributes: dict[str, Any], *children: Any) -> VdomDict:
+    """A component that renders a link to the given path."""
+    attributes = attributes.copy()
+    uuid_string = f"link-{uuid4().hex}"
+    class_name = f"{uuid_string}"
+    set_location = _use_route_state().set_location
+    if "className" in attributes:
+        class_name = " ".join([attributes.pop("className"), class_name])
+    if "class_name" in attributes:  # pragma: no cover
+        # TODO: This can be removed when ReactPy stops supporting underscores in attribute names
+        class_name = " ".join([attributes.pop("class_name"), class_name])
+    if "href" in attributes and "to" not in attributes:
+        attributes["to"] = attributes.pop("href")
+    if "to" not in attributes:  # pragma: no cover
+        raise ValueError("The `to` attribute is required for the `Link` component.")
+    to = attributes.pop("to")
+
+    attrs = {
+        **attributes,
+        "href": to,
+        "className": class_name,
+    }
+
+    # FIXME: This component currently works in a "dumb" way by trusting that ReactPy's script tag \
+    # properly sets the location due to bugs in ReactPy rendering.
+    # https://github.com/reactive-python/reactpy/pull/1224
+    current_path = use_connection().location.pathname
+
+    @event(prevent_default=True)
+    def on_click(_event: dict[str, Any]) -> None:
+        pathname, search = to.split("?", 1) if "?" in to else (to, "")
+        if search:
+            search = f"?{search}"
+
+        # Resolve relative paths that match `../foo`
+        if pathname.startswith("../"):
+            pathname = urljoin(current_path, pathname)
+
+        # Resolve relative paths that match `foo`
+        if not pathname.startswith("/"):
+            pathname = urljoin(current_path, pathname)
+
+        # Resolve relative paths that match `/foo/../bar`
+        while "/../" in pathname:
+            part_1, part_2 = pathname.split("/../", 1)
+            pathname = urljoin(f"{part_1}/", f"../{part_2}")
+
+        # Resolve relative paths that match `foo/./bar`
+        pathname = pathname.replace("/./", "/")
+
+        set_location(Location(pathname, search))
+
+    attrs["onClick"] = on_click
+
+    return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string)))
+
+    # def on_click(_event: dict[str, Any]) -> None:
+    #     set_location(Location(**_event))
+    # return html._(html.a(attrs, *children), Link({"onClick": on_click, "linkClass": uuid_string}))
+
+
+def route(path: str, element: Any | None, *routes: Route) -> Route:
+    """Create a route with the given path, element, and child routes."""
+    return Route(path, element, routes)
diff --git a/src/reactpy_router/converters.py b/src/reactpy_router/converters.py
new file mode 100644
index 0000000..5fe1b5e
--- /dev/null
+++ b/src/reactpy_router/converters.py
@@ -0,0 +1,37 @@
+import uuid
+
+from reactpy_router.types import ConversionInfo
+
+__all__ = ["CONVERTERS"]
+
+CONVERTERS: dict[str, ConversionInfo] = {
+    "int": {
+        "regex": r"\d+",
+        "func": int,
+    },
+    "str": {
+        "regex": r"[^/]+",
+        "func": str,
+    },
+    "uuid": {
+        "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
+        "func": uuid.UUID,
+    },
+    "slug": {
+        "regex": r"[-a-zA-Z0-9_]+",
+        "func": str,
+    },
+    "path": {
+        "regex": r".+",
+        "func": str,
+    },
+    "float": {
+        "regex": r"\d+(\.\d+)?",
+        "func": float,
+    },
+    "any": {
+        "regex": r".*",
+        "func": str,
+    },
+}
+"""The conversion types supported by the default Resolver."""
diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py
deleted file mode 100644
index 490a78c..0000000
--- a/src/reactpy_router/core.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""Core functionality for the reactpy-router package."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, replace
-from pathlib import Path
-from typing import Any, Callable, Iterator, Sequence, TypeVar
-from urllib.parse import parse_qs
-
-from reactpy import (
-    component,
-    create_context,
-    html,
-    use_context,
-    use_location,
-    use_memo,
-    use_state,
-)
-from reactpy.backend.hooks import ConnectionContext, use_connection
-from reactpy.backend.types import Connection, Location
-from reactpy.core.types import VdomChild, VdomDict
-from reactpy.types import ComponentType, Context
-from reactpy.web.module import export, module_from_file
-
-from reactpy_router.types import Route, RouteCompiler, Router, RouteResolver
-
-R = TypeVar("R", bound=Route)
-
-
-def route(path: str, element: Any | None, *routes: Route) -> Route:
-    """Create a route with the given path, element, and child routes"""
-    return Route(path, element, routes)
-
-
-def create_router(compiler: RouteCompiler[R]) -> Router[R]:
-    """A decorator that turns a route compiler into a router"""
-
-    def wrapper(*routes: R) -> ComponentType:
-        return router_component(*routes, compiler=compiler)
-
-    return wrapper
-
-
-@component
-def router_component(
-    *routes: R,
-    compiler: RouteCompiler[R],
-) -> VdomDict | None:
-    """A component that renders the first matching route using the given compiler"""
-
-    old_conn = use_connection()
-    location, set_location = use_state(old_conn.location)
-
-    resolvers = use_memo(
-        lambda: tuple(map(compiler, _iter_routes(routes))),
-        dependencies=(compiler, hash(routes)),
-    )
-
-    match = use_memo(lambda: _match_route(resolvers, location))
-
-    if match is not None:
-        element, params = match
-        return html._(
-            ConnectionContext(
-                _route_state_context(element, value=_RouteState(set_location, params)),
-                value=Connection(old_conn.scope, location, old_conn.carrier),
-            ),
-            _history({"on_change": lambda event: set_location(Location(**event))}),
-        )
-
-    return None
-
-
-@component
-def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict:
-    """A component that renders a link to the given path"""
-    set_location = _use_route_state().set_location
-    attrs = {
-        **attributes,
-        "to": to,
-        "onClick": lambda event: set_location(Location(**event)),
-    }
-    return _link(attrs, *children)
-
-
-def use_params() -> dict[str, Any]:
-    """Get parameters from the currently matching route pattern"""
-    return _use_route_state().params
-
-
-def use_query(
-    keep_blank_values: bool = False,
-    strict_parsing: bool = False,
-    errors: str = "replace",
-    max_num_fields: int | None = None,
-    separator: str = "&",
-) -> dict[str, list[str]]:
-    """See :func:`urllib.parse.parse_qs` for parameter info."""
-    return parse_qs(
-        use_location().search[1:],
-        keep_blank_values=keep_blank_values,
-        strict_parsing=strict_parsing,
-        errors=errors,
-        max_num_fields=max_num_fields,
-        separator=separator,
-    )
-
-
-def _iter_routes(routes: Sequence[R]) -> Iterator[R]:
-    for parent in routes:
-        for child in _iter_routes(parent.routes):
-            yield replace(child, path=parent.path + child.path)  # type: ignore[misc]
-        yield parent
-
-
-def _match_route(
-    compiled_routes: Sequence[RouteResolver], location: Location
-) -> tuple[Any, dict[str, Any]] | None:
-    for resolver in compiled_routes:
-        match = resolver.resolve(location.pathname)
-        if match is not None:
-            return match
-    return None
-
-
-_link, _history = export(
-    module_from_file("reactpy-router", file=Path(__file__).parent / "bundle.js"),
-    ("Link", "History"),
-)
-
-
-@dataclass
-class _RouteState:
-    set_location: Callable[[Location], None]
-    params: dict[str, Any]
-
-
-def _use_route_state() -> _RouteState:
-    route_state = use_context(_route_state_context)
-    assert route_state is not None
-    return route_state
-
-
-_route_state_context: Context[_RouteState | None] = create_context(None)
diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py
new file mode 100644
index 0000000..3831acf
--- /dev/null
+++ b/src/reactpy_router/hooks.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Callable
+from urllib.parse import parse_qs
+
+from reactpy import create_context, use_context, use_location
+from reactpy.backend.types import Location
+from reactpy.types import Context
+
+
+@dataclass
+class _RouteState:
+    set_location: Callable[[Location], None]
+    params: dict[str, Any]
+
+
+def _use_route_state() -> _RouteState:
+    route_state = use_context(_route_state_context)
+    if route_state is None:  # pragma: no cover
+        raise RuntimeError(
+            "ReactPy-Router was unable to find a route context. Are you "
+            "sure this hook/component is being called within a router?"
+        )
+
+    return route_state
+
+
+_route_state_context: Context[_RouteState | None] = create_context(None)
+
+
+def use_params() -> dict[str, Any]:
+    """The `use_params` hook returns an object of key/value pairs of the dynamic parameters \
+    from the current URL that were matched by the `Route`. Child routes inherit all parameters \
+    from their parent routes.
+
+    For example, if you have a `URL_PARAM` defined in the route `/example/<URL_PARAM>/`,
+    this hook will return the URL_PARAM value that was matched."""
+
+    # TODO: Check if this returns all parent params
+    return _use_route_state().params
+
+
+def use_search_params(
+    keep_blank_values: bool = False,
+    strict_parsing: bool = False,
+    errors: str = "replace",
+    max_num_fields: int | None = None,
+    separator: str = "&",
+) -> dict[str, list[str]]:
+    """
+    The `use_search_params` hook is used to read the query string in the URL \
+    for the current location.
+
+    See `urllib.parse.parse_qs` for info on this hook's parameters."""
+    location = use_location()
+    query_string = location.search[1:] if len(location.search) > 1 else ""
+
+    # TODO: In order to match `react-router`, this will need to return a tuple of the search params \
+    # and a function to update them. This is currently not possible without reactpy core having a \
+    # communication layer.
+    return parse_qs(
+        query_string,
+        keep_blank_values=keep_blank_values,
+        strict_parsing=strict_parsing,
+        errors=errors,
+        max_num_fields=max_num_fields,
+        separator=separator,
+    )
diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py
new file mode 100644
index 0000000..55c6a01
--- /dev/null
+++ b/src/reactpy_router/resolvers.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from reactpy_router.converters import CONVERTERS
+from reactpy_router.types import ConversionInfo, ConverterMapping, Route
+
+__all__ = ["StarletteResolver"]
+
+
+class StarletteResolver:
+    """URL resolver that matches routes using starlette's URL routing syntax.
+
+    However, this resolver adds a few additional parameter types on top of Starlette's syntax."""
+
+    def __init__(
+        self,
+        route: Route,
+        param_pattern=r"{(?P<name>\w+)(?P<type>:\w+)?}",
+        converters: dict[str, ConversionInfo] | None = None,
+    ) -> None:
+        self.element = route.element
+        self.registered_converters = converters or CONVERTERS
+        self.converter_mapping: ConverterMapping = {}
+        self.param_regex = re.compile(param_pattern)
+        self.pattern = self.parse_path(route.path)
+        self.key = self.pattern.pattern  # Unique identifier for ReactPy rendering
+
+    def parse_path(self, path: str) -> re.Pattern[str]:
+        # Convert path to regex pattern, then interpret using registered converters
+        pattern = "^"
+        last_match_end = 0
+
+        # Iterate through matches of the parameter pattern
+        for match in self.param_regex.finditer(path):
+            # Extract parameter name
+            name = match.group("name")
+            if name[0].isnumeric():
+                # Regex group names can't begin with a number, so we must prefix them with
+                # "_numeric_". This prefix is removed later within this function.
+                name = f"_numeric_{name}"
+
+            # Extract the parameter type
+            param_type = (match.group("type") or "str").strip(":")
+
+            # Check if a converter exists for the type
+            try:
+                conversion_info = self.registered_converters[param_type]
+            except KeyError as e:
+                raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}") from e
+
+            # Add the string before the match to the pattern
+            pattern += re.escape(path[last_match_end : match.start()])
+
+            # Add the match to the pattern
+            pattern += f"(?P<{name}>{conversion_info['regex']})"
+
+            # Keep a local mapping of the URL's parameter names to conversion functions.
+            self.converter_mapping[name] = conversion_info["func"]
+
+            # Update the last match end
+            last_match_end = match.end()
+
+        # Add the string after the last match
+        pattern += f"{re.escape(path[last_match_end:])}$"
+
+        return re.compile(pattern)
+
+    def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
+        match = self.pattern.match(path)
+        if match:
+            # Convert the matched groups to the correct types
+            params = {
+                parameter_name[len("_numeric_") :]
+                if parameter_name.startswith("_numeric_")
+                else parameter_name: self.converter_mapping[parameter_name](value)
+                for parameter_name, value in match.groupdict().items()
+            }
+            return (self.element, params)
+        return None
diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py
new file mode 100644
index 0000000..25b72c1
--- /dev/null
+++ b/src/reactpy_router/routers.py
@@ -0,0 +1,110 @@
+"""URL router implementation for ReactPy"""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from logging import getLogger
+from typing import Any, Iterator, Literal, Sequence
+
+from reactpy import component, use_memo, use_state
+from reactpy.backend.hooks import ConnectionContext, use_connection
+from reactpy.backend.types import Connection, Location
+from reactpy.core.types import VdomDict
+from reactpy.types import ComponentType
+
+from reactpy_router.components import History
+from reactpy_router.hooks import _route_state_context, _RouteState
+from reactpy_router.resolvers import StarletteResolver
+from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType
+
+__all__ = ["browser_router", "create_router"]
+_logger = getLogger(__name__)
+
+
+def create_router(resolver: Resolver[RouteType]) -> Router[RouteType]:
+    """A decorator that turns a resolver into a router"""
+
+    def wrapper(*routes: RouteType) -> ComponentType:
+        return router(*routes, resolver=resolver)
+
+    return wrapper
+
+
+browser_router = create_router(StarletteResolver)
+"""This is the recommended router for all ReactPy Router web projects.
+It uses the JavaScript DOM History API to manage the history stack."""
+
+
+@component
+def router(
+    *routes: RouteType,
+    resolver: Resolver[RouteType],
+) -> VdomDict | None:
+    """A component that renders matching route(s) using the given resolver.
+
+    This typically should never be used by a user. Instead, use `create_router` if creating
+    a custom routing engine."""
+
+    old_conn = use_connection()
+    location, set_location = use_state(old_conn.location)
+
+    resolvers = use_memo(
+        lambda: tuple(map(resolver, _iter_routes(routes))),
+        dependencies=(resolver, hash(routes)),
+    )
+
+    match = use_memo(lambda: _match_route(resolvers, location, select="first"))
+
+    if match:
+        route_elements = [
+            _route_state_context(
+                element,
+                value=_RouteState(set_location, params),
+            )
+            for element, params in match
+        ]
+
+        def on_history_change(event: dict[str, Any]) -> None:
+            """Callback function used within the JavaScript `History` component."""
+            new_location = Location(**event)
+            if location != new_location:
+                set_location(new_location)
+
+        return ConnectionContext(
+            History({"onHistoryChange": on_history_change}),  # type: ignore[return-value]
+            *route_elements,
+            value=Connection(old_conn.scope, location, old_conn.carrier),
+        )
+
+    return None
+
+
+def _iter_routes(routes: Sequence[RouteType]) -> Iterator[RouteType]:
+    for parent in routes:
+        for child in _iter_routes(parent.routes):
+            yield replace(child, path=parent.path + child.path)  # type: ignore[misc]
+        yield parent
+
+
+def _match_route(
+    compiled_routes: Sequence[CompiledRoute],
+    location: Location,
+    select: Literal["first", "all"],
+) -> list[tuple[Any, dict[str, Any]]]:
+    matches = []
+
+    for resolver in compiled_routes:
+        match = resolver.resolve(location.pathname)
+        if match is not None:
+            if select == "first":
+                return [match]
+
+            # Matching multiple routes is disabled since `react-router` no longer supports multiple
+            # matches via the `Route` component. However, it's kept here to support future changes
+            # or third-party routers.
+            matches.append(match)  # pragma: no cover
+
+    if not matches:
+        _logger.debug("No matching route found for %s", location.pathname)
+
+    return matches
diff --git a/src/reactpy_router/simple.py b/src/reactpy_router/simple.py
deleted file mode 100644
index 256f78d..0000000
--- a/src/reactpy_router/simple.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""A simple router implementation for ReactPy"""
-
-from __future__ import annotations
-
-import re
-import uuid
-from typing import Any, Callable
-
-from typing_extensions import TypeAlias, TypedDict
-
-from reactpy_router.core import create_router
-from reactpy_router.types import Route
-
-__all__ = ["router"]
-
-ConversionFunc: TypeAlias = "Callable[[str], Any]"
-ConverterMapping: TypeAlias = "dict[str, ConversionFunc]"
-
-STAR_PATTERN = re.compile("^.*$")
-PARAM_PATTERN = re.compile(r"{(?P<name>\w+)(?P<type>:\w+)?}")
-
-
-class SimpleResolver:
-    """A simple route resolver that uses regex to match paths"""
-
-    def __init__(self, route: Route) -> None:
-        self.element = route.element
-        self.pattern, self.converters = parse_path(route.path)
-        self.key = self.pattern.pattern
-
-    def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
-        match = self.pattern.match(path)
-        if match:
-            return (
-                self.element,
-                {k: self.converters[k](v) for k, v in match.groupdict().items()},
-            )
-        return None
-
-
-def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]:
-    if path == "*":
-        return STAR_PATTERN, {}
-
-    pattern = "^"
-    last_match_end = 0
-    converters: ConverterMapping = {}
-    for match in PARAM_PATTERN.finditer(path):
-        param_name = match.group("name")
-        param_type = (match.group("type") or "str").lstrip(":")
-        try:
-            param_conv = CONVERSION_TYPES[param_type]
-        except KeyError:
-            raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}")
-        pattern += re.escape(path[last_match_end : match.start()])
-        pattern += f"(?P<{param_name}>{param_conv['regex']})"
-        converters[param_name] = param_conv["func"]
-        last_match_end = match.end()
-    pattern += re.escape(path[last_match_end:]) + "$"
-    return re.compile(pattern), converters
-
-
-class ConversionInfo(TypedDict):
-    """Information about a conversion type"""
-
-    regex: str
-    """The regex to match the conversion type"""
-    func: ConversionFunc
-    """The function to convert the matched string to the expected type"""
-
-
-CONVERSION_TYPES: dict[str, ConversionInfo] = {
-    "str": {
-        "regex": r"[^/]+",
-        "func": str,
-    },
-    "int": {
-        "regex": r"\d+",
-        "func": int,
-    },
-    "float": {
-        "regex": r"\d+(\.\d+)?",
-        "func": float,
-    },
-    "uuid": {
-        "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
-        "func": uuid.UUID,
-    },
-    "path": {
-        "regex": r".+",
-        "func": str,
-    },
-}
-"""The supported conversion types"""
-
-
-router = create_router(SimpleResolver)
-"""The simple router"""
diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js
new file mode 100644
index 0000000..0ce08b9
--- /dev/null
+++ b/src/reactpy_router/static/link.js
@@ -0,0 +1,8 @@
+document.querySelector(".UUID").addEventListener(
+  "click",
+  (event) => {
+    let to = event.target.getAttribute("href");
+    window.history.pushState({}, to, new URL(to, window.location));
+  },
+  { once: true },
+);
diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py
index a91787e..15a77c4 100644
--- a/src/reactpy_router/types.py
+++ b/src/reactpy_router/types.py
@@ -1,27 +1,30 @@
-"""Types for reactpy_router"""
+"""Type definitions for the `reactpy-router` package."""
 
 from __future__ import annotations
 
 from dataclasses import dataclass, field
-from typing import Any, Sequence, TypeVar
+from typing import Any, Callable, Sequence, TypedDict, TypeVar
 
 from reactpy.core.vdom import is_vdom
 from reactpy.types import ComponentType, Key
-from typing_extensions import Protocol, Self
+from typing_extensions import Protocol, Self, TypeAlias
+
+ConversionFunc: TypeAlias = Callable[[str], Any]
+ConverterMapping: TypeAlias = dict[str, ConversionFunc]
 
 
 @dataclass(frozen=True)
 class Route:
-    """A route that can be matched against a path"""
+    """A route that can be matched against a path."""
 
     path: str
-    """The path to match against"""
+    """The path to match against."""
 
     element: Any = field(hash=False)
-    """The element to render if the path matches"""
+    """The element to render if the path matches."""
 
     routes: Sequence[Self]
-    """Child routes"""
+    """Child routes."""
 
     def __hash__(self) -> int:
         el = self.element
@@ -29,29 +32,37 @@ def __hash__(self) -> int:
         return hash((self.path, key, self.routes))
 
 
-R = TypeVar("R", bound=Route, contravariant=True)
+RouteType = TypeVar("RouteType", bound=Route)
+RouteType_contra = TypeVar("RouteType_contra", bound=Route, contravariant=True)
 
 
-class Router(Protocol[R]):
-    """Return a component that renders the first matching route"""
+class Router(Protocol[RouteType_contra]):
+    """Return a component that renders the first matching route."""
 
-    def __call__(self, *routes: R) -> ComponentType:
-        ...
+    def __call__(self, *routes: RouteType_contra) -> ComponentType: ...
 
 
-class RouteCompiler(Protocol[R]):
-    """Compile a route into a resolver that can be matched against a path"""
+class Resolver(Protocol[RouteType_contra]):
+    """Compile a route into a resolver that can be matched against a given path."""
 
-    def __call__(self, route: R) -> RouteResolver:
-        ...
+    def __call__(self, route: RouteType_contra) -> CompiledRoute: ...
 
 
-class RouteResolver(Protocol):
-    """A compiled route that can be matched against a path"""
+class CompiledRoute(Protocol):
+    """A compiled route that can be matched against a path."""
 
     @property
     def key(self) -> Key:
-        """Uniquely identified this resolver"""
+        """Uniquely identified this resolver."""
 
     def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
-        """Return the path's associated element and path params or None"""
+        """Return the path's associated element and path parameters or None."""
+
+
+class ConversionInfo(TypedDict):
+    """Information about a conversion type."""
+
+    regex: str
+    """The regex to match the conversion type."""
+    func: ConversionFunc
+    """The function to convert the matched string to the expected type."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 573eba5..18e3646 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,31 +1,44 @@
+import asyncio
+import os
+import sys
+
 import pytest
 from playwright.async_api import async_playwright
 from reactpy.testing import BackendFixture, DisplayFixture
 
+GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
+
 
 def pytest_addoption(parser) -> None:
     parser.addoption(
-        "--headed",
-        dest="headed",
+        "--headless",
+        dest="headless",
         action="store_true",
-        help="Open a browser window when runnging web-based tests",
+        help="Hide the browser window when running web-based tests",
     )
 
 
 @pytest.fixture
 async def display(backend, browser):
-    async with DisplayFixture(backend, browser) as display:
-        display.page.set_default_timeout(10000)
-        yield display
+    async with DisplayFixture(backend, browser) as display_fixture:
+        display_fixture.page.set_default_timeout(10000)
+        yield display_fixture
 
 
 @pytest.fixture
 async def backend():
-    async with BackendFixture() as backend:
-        yield backend
+    async with BackendFixture() as backend_fixture:
+        yield backend_fixture
 
 
 @pytest.fixture
 async def browser(pytestconfig):
     async with async_playwright() as pw:
-        yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed))
+        yield await pw.chromium.launch(headless=True if GITHUB_ACTIONS else pytestconfig.getoption("headless"))
+
+
+@pytest.fixture
+def event_loop_policy(request):
+    if sys.platform == "win32":
+        return asyncio.WindowsProactorEventLoopPolicy()
+    return asyncio.get_event_loop_policy()
diff --git a/tests/test_core.py b/tests/test_core.py
index 77577b3..390236d 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,8 +1,13 @@
+import os
 from typing import Any
 
 from reactpy import Ref, component, html, use_location
 from reactpy.testing import DisplayFixture
-from reactpy_router import link, route, simple, use_params, use_query
+
+from reactpy_router import browser_router, link, route, use_params, use_search_params
+
+GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
+CLICK_DELAY = 350 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
 
 
 async def test_simple_router(display: DisplayFixture):
@@ -18,7 +23,7 @@ def check_location():
 
     @component
     def sample():
-        return simple.router(
+        return browser_router(
             make_location_check("/a"),
             make_location_check("/b"),
             make_location_check("/c"),
@@ -40,7 +45,8 @@ def sample():
         root_element = await display.root_element()
     except AttributeError:
         root_element = await display.page.wait_for_selector(
-            f"#display-{display._next_view_id}", state="attached"  # type: ignore
+            f"#display-{display._next_view_id}",  # type: ignore
+            state="attached",
         )
 
     assert not await root_element.inner_html()
@@ -49,7 +55,7 @@ def sample():
 async def test_nested_routes(display: DisplayFixture):
     @component
     def sample():
-        return simple.router(
+        return browser_router(
             route(
                 "/a",
                 html.h1({"id": "a"}, "A"),
@@ -78,19 +84,19 @@ async def test_navigate_with_link(display: DisplayFixture):
     @component
     def sample():
         render_count.current += 1
-        return simple.router(
-            route("/", link("Root", to="/a", id="root")),
-            route("/a", link("A", to="/b", id="a")),
-            route("/b", link("B", to="/c", id="b")),
-            route("/c", link("C", to="/default", id="c")),
-            route("*", html.h1({"id": "default"}, "Default")),
+        return browser_router(
+            route("/", link({"to": "/a", "id": "root"}, "Root")),
+            route("/a", link({"to": "/b", "id": "a"}, "A")),
+            route("/b", link({"to": "/c", "id": "b"}, "B")),
+            route("/c", link({"to": "/default", "id": "c"}, "C")),
+            route("{default:any}", html.h1({"id": "default"}, "Default")),
         )
 
     await display.show(sample)
 
     for link_selector in ["#root", "#a", "#b", "#c"]:
-        lnk = await display.page.wait_for_selector(link_selector)
-        await lnk.click()
+        _link = await display.page.wait_for_selector(link_selector)
+        await _link.click(delay=CLICK_DELAY)
 
     await display.page.wait_for_selector("#default")
 
@@ -109,7 +115,7 @@ def check_params():
 
     @component
     def sample():
-        return simple.router(
+        return browser_router(
             route(
                 "/first/{first:str}",
                 check_params(),
@@ -135,17 +141,17 @@ def sample():
         await display.page.wait_for_selector("#success")
 
 
-async def test_use_query(display: DisplayFixture):
+async def test_search_params(display: DisplayFixture):
     expected_query: dict[str, Any] = {}
 
     @component
     def check_query():
-        assert use_query() == expected_query
+        assert use_search_params() == expected_query
         return html.h1({"id": "success"}, "success")
 
     @component
     def sample():
-        return simple.router(route("/", check_query()))
+        return browser_router(route("/", check_query()))
 
     await display.show(sample)
 
@@ -157,19 +163,19 @@ def sample():
 async def test_browser_popstate(display: DisplayFixture):
     @component
     def sample():
-        return simple.router(
-            route("/", link("Root", to="/a", id="root")),
-            route("/a", link("A", to="/b", id="a")),
-            route("/b", link("B", to="/c", id="b")),
-            route("/c", link("C", to="/default", id="c")),
-            route("*", html.h1({"id": "default"}, "Default")),
+        return browser_router(
+            route("/", link({"to": "/a", "id": "root"}, "Root")),
+            route("/a", link({"to": "/b", "id": "a"}, "A")),
+            route("/b", link({"to": "/c", "id": "b"}, "B")),
+            route("/c", link({"to": "/default", "id": "c"}, "C")),
+            route("{default:any}", html.h1({"id": "default"}, "Default")),
         )
 
     await display.show(sample)
 
     for link_selector in ["#root", "#a", "#b", "#c"]:
-        lnk = await display.page.wait_for_selector(link_selector)
-        await lnk.click()
+        _link = await display.page.wait_for_selector(link_selector)
+        await _link.click(delay=CLICK_DELAY)
 
     await display.page.wait_for_selector("#default")
 
@@ -189,24 +195,28 @@ def sample():
 async def test_relative_links(display: DisplayFixture):
     @component
     def sample():
-        return simple.router(
-            route("/", link("Root", to="/a", id="root")),
-            route("/a", link("A", to="/a/b", id="a")),
-            route("/a/b", link("B", to="../a/b/c", id="b")),
-            route("/a/b/c", link("C", to="../d", id="c")),
-            route("/a/d", link("D", to="e", id="d")),
-            route("/a/e", link("E", to="../default", id="e")),
-            route("*", html.h1({"id": "default"}, "Default")),
+        return browser_router(
+            route("/", link({"to": "a", "id": "root"}, "Root")),
+            route("/a", link({"to": "/a/a/../b", "id": "a"}, "A")),
+            route("/a/b", link({"to": "../a/b/c", "id": "b"}, "B")),
+            route("/a/b/c", link({"to": "../d", "id": "c"}, "C")),
+            route("/a/d", link({"to": "e", "id": "d"}, "D")),
+            route("/a/e", link({"to": "/a/./f", "id": "e"}, "E")),
+            route("/a/f", link({"to": "../default", "id": "f"}, "F")),
+            route("{default:any}", html.h1({"id": "default"}, "Default")),
         )
 
     await display.show(sample)
 
-    for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e"]:
-        lnk = await display.page.wait_for_selector(link_selector)
-        await lnk.click()
+    for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]:
+        _link = await display.page.wait_for_selector(link_selector)
+        await _link.click(delay=CLICK_DELAY)
 
     await display.page.wait_for_selector("#default")
 
+    await display.page.go_back()
+    await display.page.wait_for_selector("#f")
+
     await display.page.go_back()
     await display.page.wait_for_selector("#e")
 
@@ -224,3 +234,46 @@ def sample():
 
     await display.page.go_back()
     await display.page.wait_for_selector("#root")
+
+
+async def test_link_with_query_string(display: DisplayFixture):
+    @component
+    def check_search_params():
+        query = use_search_params()
+        assert query == {"a": ["1"], "b": ["2"]}
+        return html.h1({"id": "success"}, "success")
+
+    @component
+    def sample():
+        return browser_router(
+            route("/", link({"to": "/a?a=1&b=2", "id": "root"}, "Root")),
+            route("/a", check_search_params()),
+        )
+
+    await display.show(sample)
+    await display.page.wait_for_selector("#root")
+    _link = await display.page.wait_for_selector("#root")
+    await _link.click(delay=CLICK_DELAY)
+    await display.page.wait_for_selector("#success")
+
+
+async def test_link_class_name(display: DisplayFixture):
+    @component
+    def sample():
+        return browser_router(route("/", link({"to": "/a", "id": "root", "className": "class1"}, "Root")))
+
+    await display.show(sample)
+
+    _link = await display.page.wait_for_selector("#root")
+    assert "class1" in await _link.get_attribute("class")
+
+
+async def test_link_href(display: DisplayFixture):
+    @component
+    def sample():
+        return browser_router(route("/", link({"href": "/a", "id": "root"}, "Root")))
+
+    await display.show(sample)
+
+    _link = await display.page.wait_for_selector("#root")
+    assert "/a" in await _link.get_attribute("href")
diff --git a/tests/test_resolver.py b/tests/test_resolver.py
new file mode 100644
index 0000000..4e8a669
--- /dev/null
+++ b/tests/test_resolver.py
@@ -0,0 +1,57 @@
+import re
+import uuid
+
+import pytest
+
+from reactpy_router import route
+from reactpy_router.resolvers import StarletteResolver
+
+
+def test_resolve_any():
+    resolver = StarletteResolver(route("{404:any}", "Hello World"))
+    assert resolver.parse_path("{404:any}") == re.compile("^(?P<_numeric_404>.*)$")
+    assert resolver.converter_mapping == {"_numeric_404": str}
+    assert resolver.resolve("/hello/world") == ("Hello World", {"404": "/hello/world"})
+
+
+def test_parse_path():
+    resolver = StarletteResolver(route("/", None))
+    assert resolver.parse_path("/a/b/c") == re.compile("^/a/b/c$")
+    assert resolver.converter_mapping == {}
+
+    assert resolver.parse_path("/a/{b}/c") == re.compile(r"^/a/(?P<b>[^/]+)/c$")
+    assert resolver.converter_mapping == {"b": str}
+
+    assert resolver.parse_path("/a/{b:int}/c") == re.compile(r"^/a/(?P<b>\d+)/c$")
+    assert resolver.converter_mapping == {"b": int}
+
+    assert resolver.parse_path("/a/{b:int}/{c:float}/c") == re.compile(r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/c$")
+    assert resolver.converter_mapping == {"b": int, "c": float}
+
+    assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == re.compile(
+        r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/c$"
+    )
+    assert resolver.converter_mapping == {"b": int, "c": float, "d": uuid.UUID}
+
+    assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == re.compile(
+        r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P<e>.+)/c$"
+    )
+    assert resolver.converter_mapping == {
+        "b": int,
+        "c": float,
+        "d": uuid.UUID,
+        "e": str,
+    }
+
+
+def test_parse_path_unkown_conversion():
+    resolver = StarletteResolver(route("/", None))
+    with pytest.raises(ValueError):
+        resolver.parse_path("/a/{b:unknown}/c")
+
+
+def test_parse_path_re_escape():
+    """Check that we escape regex characters in the path"""
+    resolver = StarletteResolver(route("/", None))
+    assert resolver.parse_path("/a/{b:int}/c.d") == re.compile(r"^/a/(?P<b>\d+)/c\.d$")
+    assert resolver.converter_mapping == {"b": int}
diff --git a/tests/test_simple.py b/tests/test_simple.py
deleted file mode 100644
index 9ec8a2b..0000000
--- a/tests/test_simple.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import re
-import uuid
-
-import pytest
-
-from reactpy_router.simple import parse_path
-
-
-def test_parse_path():
-    assert parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {})
-    assert parse_path("/a/{b}/c") == (
-        re.compile(r"^/a/(?P<b>[^/]+)/c$"),
-        {"b": str},
-    )
-    assert parse_path("/a/{b:int}/c") == (
-        re.compile(r"^/a/(?P<b>\d+)/c$"),
-        {"b": int},
-    )
-    assert parse_path("/a/{b:int}/{c:float}/c") == (
-        re.compile(r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/c$"),
-        {"b": int, "c": float},
-    )
-    assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == (
-        re.compile(
-            r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
-            r"0-9a-f]{4}-[0-9a-f]{12})/c$"
-        ),
-        {"b": int, "c": float, "d": uuid.UUID},
-    )
-    assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == (
-        re.compile(
-            r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
-            r"0-9a-f]{4}-[0-9a-f]{12})/(?P<e>.+)/c$"
-        ),
-        {"b": int, "c": float, "d": uuid.UUID, "e": str},
-    )
-
-
-def test_parse_path_unkown_conversion():
-    with pytest.raises(ValueError):
-        parse_path("/a/{b:unknown}/c")
-
-
-def test_parse_path_re_escape():
-    """Check that we escape regex characters in the path"""
-    assert parse_path("/a/{b:int}/c.d") == (
-        #                          ^ regex character
-        re.compile(r"^/a/(?P<b>\d+)/c\.d$"),
-        {"b": int},
-    )
-
-
-def test_match_star_path():
-    assert parse_path("*") == (re.compile("^.*$"), {})