Skip to content

Add modular Router support#731

Merged
sdogruyol merged 4 commits intomasterfrom
modular-router
Feb 9, 2026
Merged

Add modular Router support#731
sdogruyol merged 4 commits intomasterfrom
modular-router

Conversation

@sdogruyol
Copy link
Member

@sdogruyol sdogruyol commented Jan 29, 2026

Summary

This PR introduces a modular router feature to Kemal, inspired by Flask's Blueprint and Express.js's Router. It enables organizing routes into logical groups with shared prefixes and scoped middleware, while maintaining full backward compatibility with the existing DSL.

Motivation

As Kemal applications grow, organizing routes becomes challenging with the flat global DSL. This feature allows:

  • Grouping related routes under common prefixes
  • Applying filters (middleware) to specific route groups only
  • Better code organization with separate router files
  • API versioning support

New Features

1. Basic Router with HTTP Methods

router = Kemal::Router.new

router.get "/users" do |env|
  "list users"
end

router.post "/users" do |env|
  "create user"
end

router.put "/users/:id" do |env|
  "update user #{env.params.url["id"]}"
end

router.patch "/users/:id" do |env|
  "patch user #{env.params.url["id"]}"
end

router.delete "/users/:id" do |env|
  "delete user #{env.params.url["id"]}"
end

router.options "/users" do |env|
  env.response.headers["Allow"] = "GET, POST, OPTIONS"
end

mount "/api", router
# Results:
# GET /api/users
# POST /api/users
# PUT /api/users/:id
# PATCH /api/users/:id
# DELETE /api/users/:id
# OPTIONS /api/users

2. Router-Scoped Filters

api = Kemal::Router.new

# Before filter for all routes in this router
api.before do |env|
  env.response.content_type = "application/json"
end

# After filter for all routes
api.after do |env|
  env.response.headers["X-Response-Time"] = "10ms"
end

# Method-specific filters
api.before_post do |env|
  # Only runs before POST requests
  env.set "validated", "true"
end

api.after_get do |env|
  # Only runs after GET requests
end

# Path-specific filter
api.before "/admin" do |env|
  halt env, 403, "Forbidden" unless admin?(env)
end

api.get "/users" do |env|
  {users: []}.to_json
end

api.get "/admin" do |env|
  {admin: true}.to_json
end

mount "/api", api

3. Nested Routing with namespace

api = Kemal::Router.new

api.namespace "/users" do
  get "/" do |env|
    "list all users"
  end

  get "/:id" do |env|
    "show user #{env.params.url["id"]}"
  end

  post "/" do |env|
    "create user"
  end

  put "/:id" do |env|
    "update user #{env.params.url["id"]}"
  end

  delete "/:id" do |env|
    "delete user #{env.params.url["id"]}"
  end
end

api.namespace "/posts" do
  get "/" do |env|
    "list all posts"
  end

  get "/:id" do |env|
    "show post #{env.params.url["id"]}"
  end
end

mount "/api/v1", api
# Results:
#   GET    /api/v1/users
#   GET    /api/v1/users/:id
#   POST   /api/v1/users
#   PUT    /api/v1/users/:id
#   DELETE /api/v1/users/:id
#   GET    /api/v1/posts
#   GET    /api/v1/posts/:id

4. Deeply Nested Routers

router = Kemal::Router.new

router.namespace "/api" do
  namespace "/v1" do
    namespace "/users" do
      get "/" do |env|
        "deeply nested users list"
      end

      get "/:id" do |env|
        "user #{env.params.url["id"]}"
      end
    end
  end
end

mount router
# Results:
#   GET /api/v1/users
#   GET /api/v1/users/:id

5. Mounting Sub-Routers

# Define separate routers
users_router = Kemal::Router.new
users_router.get "/" do |env|
  "users list"
end
users_router.get "/:id" do |env|
  "user #{env.params.url["id"]}"
end

posts_router = Kemal::Router.new
posts_router.get "/" do |env|
  "posts list"
end

# Combine them
api = Kemal::Router.new
api.mount "/users", users_router
api.mount "/posts", posts_router

mount "/api";, api
# Results:
#   GET /api/users
#   GET /api/users/:id
#   GET /api/posts

6. WebSocket Support

chat = Kemal::Router.new

chat.ws "/room/:id" do |socket, env|
  room_id = env.params.url["id"]
  
  socket.on_message do |msg|
    socket.send "Room #{room_id}: #{msg}"
  end

  socket.on_close do
    puts "Connection closed"
  end
end

7. Router with Initial Prefix

v2_api = Kemal::Router.new("/v2")

v2_api.get "/status" do |env|
  {version: "2.0", status: "ok"}.to_json
end

mount "/api", v2_api
# Result: GET /api/v2/status

8. Mount Without Prefix

router = Kemal::Router.new

router.get "/health" do |env|
  "OK"
end

mount router  # No prefix
# Result: GET /health

9. Backward Compatibility

# Old DSL still works exactly as before
get "/" do |env|
  "Hello World"
end

before_all do |env|
  env.response.headers["X-Frame-Options"] = "DENY"
end

# New router alongside old DSL
api = Kemal::Router.new
api.get "/status" do |env|
  {status: "ok"}.to_json
end

mount "/api", api

Kemal.run
# Both work:
#   GET / -> "Hello World"
#   GET /api/status -> {"status": "ok"}

10. Filters with Namespace

api = Kemal::Router.new

api.namespace "/admin" do
  # This filter only applies to routes inside this namespace
  before do |env|
    halt env, 401 unless admin?(env)
  end

  get "/dashboard" do |env|
    "admin only"
  end
end

api.get "/public" do |env|
  "anyone can access"
end

mount "/api", api
# /api/admin/dashboard -> requires admin (filter applied)
# /api/public -> no filter

@sdogruyol
Copy link
Member Author

//cc ping @Sija @straight-shoota

@hugopl
Copy link
Contributor

hugopl commented Jan 30, 2026

This is awesome, sidekiq has a Web UI based on Kemal, but with the current API there's no way to mount the sidekiq dashboard into the application with proper authorization routines, but this patch makes it possible.

@jwoertink
Copy link
Contributor

This is huge! Great job. I've always wanted to be able to mount a mini Kemal app inside of Lucky, so this will be perfect 🙌

Copy link
Contributor

@Sija Sija left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1st round

Copy link
Contributor

@Sija Sija left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2nd round

Copy link
Contributor

@Sija Sija left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

Copy link

@Tuntii Tuntii left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks solid

@sdogruyol sdogruyol merged commit c65e6d1 into master Feb 9, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

Comments