Kemal Core Development
This skill provides expert guidance on using the Kemal web framework for Crystal, with version notes for when APIs landed.
Version Notes
Everything in this skill works on current Kemal unless marked otherwise.
- HTTP
QUERY method (RFC 10008) — query, before_query, after_query: since Kemal 1.13.0 (on 1.12.0 and earlier, use post or get with query parameters).
Kemal.config.max_ranges (Range request bounds): since Kemal 1.13.0.
- Disabled
X-Powered-By header by default (Kemal.config.powered_by_header = false): since Kemal 1.13.0.
- Response helpers (
env.json, env.status, env.html, env.text): since Kemal 1.10.
Kemal::Router, mount, namespace: since Kemal 1.10.
Kemal.config.max_request_body_size: since Kemal 1.9. Kemal.config.shutdown_timeout: since Kemal 1.10.1.
Core Mandates
Routing: Use top-level route methods (get, post, put, patch, delete, options) or modular routers (Kemal::Router).
*HTTP QUERY Method (RFC 10008) — [Kemal 1.13.0+]:*
- On Kemal 1.13.0+, use
query for safe, read-only queries with complex request bodies (JSON or form-encoded):
# Kemal 1.13.0+:
query "/search" do |env|
q = env.params.json["q"]?.as?(String)
halt env.status(:bad_request).json({error: "Query parameter 'q' required"}) unless q
results = Product.search(q)
env.json({results: results})
end
Note: A QUERY request carrying a body without a Content-Type header is rejected with 400 Bad Request. On 1.12.0 and earlier, use post or query parameters via get instead.
Modular Routers (Kemal 1.10+): Use Kemal::Router.new for namespaced routes, scoped middleware, and mounting under path prefixes:
api = Kemal::Router.new
api.namespace "/users" do
get "/" do |env|
env.json({users: %w[alice bob]})
end
get "/:id" do |env|
env.text "user #{env.params.url["id"]?}"
end
end
mount "/api/v1", api
Parameters: Match parameters to request encoding (they are not interchangeable):
- URL parameters:
env.params.url["id"] (raises on missing key) or env.params.url["id"]? (safe access)
- Body parameters (
application/x-www-form-urlencoded or multipart): env.params.body["name"]?
- Query parameters (
?key=val): env.params.query["search"]?
- JSON parameters (
application/json): env.params.json["field"]?.as?(String)
- File parameters:
env.params.files["file"]
- Raw request body:
env.params.raw_body (for multi-handler raw body access)
Response Helpers: Use context response helpers (env.json, env.status, env.html, env.text, halt). For deep JSON API patterns and status helpers, refer to kemal-json.
Rendering: Use the render macro with view and optional layout paths (see kemal-view):
render "src/views/posts/index.ecr", "src/views/layouts/application.ecr"
Middleware Registration: Use use (Kemal 1.10+) or Kemal.config.add_handler (see kemal-middleware):
- Path-specific middleware:
use "/api", [CORSHandler.new, AuthHandler.new]
- Global middleware:
use MyHandler.new
Patterns from Source Code
URL Parameter Access (Safe Pattern)
Always use the safe pattern for URL parameters to handle missing or invalid IDs:
# Use the `?` accessor and `to_i64?` for IDs:
id = env.params.url["id"]?.try(&.to_i64?)
halt env.status(:bad_request).json({error: "Invalid ID"}) unless id
post = Post.find(id)
Body Parameter Access & Raw Body
Always use safe access with try for body parameters:
title = env.params.body["title"]?.try(&.strip) || ""
body = env.params.body["body"]?.try(&.strip) || ""
# Access raw request body across multiple handlers:
raw = env.params.raw_body
Modular Router with Namespaces
Organize sub-systems cleanly using Kemal::Router:
require "kemal"
admin_router = Kemal::Router.new
admin_router.namespace "/posts" do
get "/" do |env|
posts = Post.all
env.json(posts.map(&.to_h))
end
get "/:id" do |env|
id = env.params.url["id"]?.try(&.to_i64?)
post = id ? Post.find(id) : nil
if post
env.json(post.to_h)
else
halt env.status(:not_found).json({error: "Post not found"})
end
end
# HTTP QUERY (Kemal 1.13.0+):
query "/search" do |env|
term = env.params.json["term"]?.as?(String)
halt env.status(:bad_request).json({error: "Search term required"}) unless term
posts = Post.search(term)
env.json(posts.map(&.to_h))
end
end
mount "/admin", admin_router
Best Practices
- Separation of Concerns: Keep route logic minimal. Delegate complex operations to models or services.
- Static Files: Kemal serves files from the
public directory by default. Configure this via Kemal.config.public_folder.
- Request Body Size Limits (Kemal 1.9+): Limit maximum request body size to prevent DoS:
Kemal.config.max_request_body_size = 50 * 1024 * 1024 # 50 MB
- Range Request Bounds (Kemal 1.13.0+): Kemal bounds HTTP
Range request parts (default 16) to mitigate CVE-2011-3192 resource exhaustion:# Kemal 1.13.0+:
Kemal.config.max_ranges = 16 # set to 0 to ignore Range headers entirely
- Powered-By Header (Kemal 1.13.0+): Kemal disables the
X-Powered-By: Kemal response header by default. To re-enable it if required:Kemal.config.powered_by_header = true
- Graceful Shutdown (Kemal 1.10.1+): Configure shutdown timeout so in-flight requests finish cleanly before exit:
Kemal.config.shutdown_timeout = 10.seconds
When to Use
- When creating or modifying routes in a Kemal application.
- When organizing modular route namespaces with
Kemal::Router (Kemal 1.10+).
- When handling incoming request parameters (URL, body, query, JSON, files, raw body).
- When implementing search/filter endpoints (using
get/post on 1.12.0 and earlier, or query on 1.13.0+).
- When returning JSON, HTML, or plain text responses.
- When configuring global runtime settings and security bounds for Kemal.
1---2name: kemal-core3description: Core Kemal development (routing verbs, parameters, modular router, version gates, response helpers).4license: MIT5---67# Kemal Core Development89This skill provides expert guidance on using the Kemal web framework for Crystal, with version notes for when APIs landed.1011## Version Notes1213Everything in this skill works on current Kemal unless marked otherwise.1415- HTTP `QUERY` method (RFC 10008) — `query`, `before_query`, `after_query`: since Kemal 1.13.0 (on 1.12.0 and earlier, use `post` or `get` with query parameters).16- `Kemal.config.max_ranges` (Range request bounds): since Kemal 1.13.0.17- Disabled `X-Powered-By` header by default (`Kemal.config.powered_by_header = false`): since Kemal 1.13.0.18- Response helpers (`env.json`, `env.status`, `env.html`, `env.text`): since Kemal 1.10.19- `Kemal::Router`, `mount`, `namespace`: since Kemal 1.10.20- `Kemal.config.max_request_body_size`: since Kemal 1.9. `Kemal.config.shutdown_timeout`: since Kemal 1.10.1.2122## Core Mandates2324- **Routing:** Use top-level route methods (`get`, `post`, `put`, `patch`, `delete`, `options`) or modular routers (`Kemal::Router`).25- **HTTP QUERY Method (RFC 10008) — *[Kemal 1.13.0+]*:**26 - On Kemal 1.13.0+, use `query` for safe, read-only queries with complex request bodies (JSON or form-encoded):2728 ```crystal29 # Kemal 1.13.0+:30 query "/search" do |env|31 q = env.params.json["q"]?.as?(String)32 halt env.status(:bad_request).json({error: "Query parameter 'q' required"}) unless q33 results = Product.search(q)34 env.json({results: results})35 end36 ```3738 *Note*: A `QUERY` request carrying a body without a `Content-Type` header is rejected with `400 Bad Request`. On 1.12.0 and earlier, use `post` or query parameters via `get` instead.3940- **Modular Routers (Kemal 1.10+):** Use `Kemal::Router.new` for namespaced routes, scoped middleware, and mounting under path prefixes:4142 ```crystal43 api = Kemal::Router.new44 api.namespace "/users" do45 get "/" do |env|46 env.json({users: %w[alice bob]})47 end48 get "/:id" do |env|49 env.text "user #{env.params.url["id"]?}"50 end51 end5253 mount "/api/v1", api54 ```5556- **Parameters:** Match parameters to request encoding (they are not interchangeable):57 - URL parameters: `env.params.url["id"]` (raises on missing key) or `env.params.url["id"]?` (safe access)58 - Body parameters (`application/x-www-form-urlencoded` or multipart): `env.params.body["name"]?`59 - Query parameters (`?key=val`): `env.params.query["search"]?`60 - JSON parameters (`application/json`): `env.params.json["field"]?.as?(String)`61 - File parameters: `env.params.files["file"]`62 - Raw request body: `env.params.raw_body` (for multi-handler raw body access)6364- **Response Helpers:** Use context response helpers (`env.json`, `env.status`, `env.html`, `env.text`, `halt`). For deep JSON API patterns and status helpers, refer to [`kemal-json`](../kemal-json/SKILL.md).6566- **Rendering:** Use the `render` macro with view and optional layout paths (see [`kemal-view`](../kemal-view/SKILL.md)):6768 ```crystal69 render "src/views/posts/index.ecr", "src/views/layouts/application.ecr"70 ```7172- **Middleware Registration:** Use `use` (Kemal 1.10+) or `Kemal.config.add_handler` (see [`kemal-middleware`](../kemal-middleware/SKILL.md)):73 - Path-specific middleware: `use "/api", [CORSHandler.new, AuthHandler.new]`74 - Global middleware: `use MyHandler.new`7576## Patterns from Source Code7778### URL Parameter Access (Safe Pattern)7980Always use the safe pattern for URL parameters to handle missing or invalid IDs:8182```crystal83# Use the `?` accessor and `to_i64?` for IDs:84id = env.params.url["id"]?.try(&.to_i64?)85halt env.status(:bad_request).json({error: "Invalid ID"}) unless id86post = Post.find(id)87```8889### Body Parameter Access & Raw Body9091Always use safe access with `try` for body parameters:9293```crystal94title = env.params.body["title"]?.try(&.strip) || ""95body = env.params.body["body"]?.try(&.strip) || ""9697# Access raw request body across multiple handlers:98raw = env.params.raw_body99```100101### Modular Router with Namespaces102103Organize sub-systems cleanly using `Kemal::Router`:104105```crystal106require "kemal"107108admin_router = Kemal::Router.new109110admin_router.namespace "/posts" do111 get "/" do |env|112 posts = Post.all113 env.json(posts.map(&.to_h))114 end115116 get "/:id" do |env|117 id = env.params.url["id"]?.try(&.to_i64?)118 post = id ? Post.find(id) : nil119 if post120 env.json(post.to_h)121 else122 halt env.status(:not_found).json({error: "Post not found"})123 end124 end125126 # HTTP QUERY (Kemal 1.13.0+):127 query "/search" do |env|128 term = env.params.json["term"]?.as?(String)129 halt env.status(:bad_request).json({error: "Search term required"}) unless term130 posts = Post.search(term)131 env.json(posts.map(&.to_h))132 end133end134135mount "/admin", admin_router136```137138## Best Practices139140- **Separation of Concerns:** Keep route logic minimal. Delegate complex operations to models or services.141- **Static Files:** Kemal serves files from the `public` directory by default. Configure this via `Kemal.config.public_folder`.142- **Request Body Size Limits (Kemal 1.9+):** Limit maximum request body size to prevent DoS:143 ```crystal144 Kemal.config.max_request_body_size = 50 * 1024 * 1024 # 50 MB145 ```146- **Range Request Bounds (Kemal 1.13.0+):** Kemal bounds HTTP `Range` request parts (default 16) to mitigate CVE-2011-3192 resource exhaustion:147 ```crystal148 # Kemal 1.13.0+:149 Kemal.config.max_ranges = 16 # set to 0 to ignore Range headers entirely150 ```151- **Powered-By Header (Kemal 1.13.0+):** Kemal disables the `X-Powered-By: Kemal` response header by default. To re-enable it if required:152 ```crystal153 Kemal.config.powered_by_header = true154 ```155- **Graceful Shutdown (Kemal 1.10.1+):** Configure shutdown timeout so in-flight requests finish cleanly before exit:156 ```crystal157 Kemal.config.shutdown_timeout = 10.seconds158 ```159160## When to Use161162- When creating or modifying routes in a Kemal application.163- When organizing modular route namespaces with `Kemal::Router` (Kemal 1.10+).164- When handling incoming request parameters (URL, body, query, JSON, files, raw body).165- When implementing search/filter endpoints (using `get`/`post` on 1.12.0 and earlier, or `query` on 1.13.0+).166- When returning JSON, HTML, or plain text responses.167- When configuring global runtime settings and security bounds for Kemal.