I have two go-to technologies for my personal projects: Crystal and Nuxt. I love Nuxt for how easy it is to spin up a full-stack server and the incredible Developer Experience (DX) it provides. On the other hand, I love Crystal for its simplicity and raw speed.
As I dove deeper into Crystal, I found myself wondering: Is it possible to have file-based routing like in Nuxt (powered by Nitro)?
So, I decided to build it myself. To replicate this behavior in Crystal, I broke the problem down into three main challenges:
- Discovery: Reading the file tree to find route definitions.
- Importing: Dynamically importing the handlers for each route.
- Registration: Constructing the routes and serving them via an HTTP server.
The Challenge: Dynamic Imports
I knew from the start that the biggest hurdle would be importing code dynamically. In JavaScript, it is trivial to traverse a directory and import or require files on the fly.
But in Crystal? Since it is a compiled language, I can’t simply "require" a file whose name I don’t know until the program runs. The compiler needs to know everything upfront.
After some time thinking on the problem, I remembered: Macros.
What are Macros?
In Crystal, macros are methods that execute during compilation. They allow you to generate code, inspect the program structure, and significantly reduce boilerplate.
When the Crystal compiler encounters a macro call, it executes it and pastes the resulting code into the program before compiling the final executable.
For example, here is a macro that generates a method to print values:
macro define_method(name, content) def {{name}} puts {{content}} end end # Usage define_method(:say_hello, "Hello from a macro!") # The compiler expands this to: # def say_hello # puts "Hello from a macro!" # end say_hello # Output: Hello from a macro!
With this in mind, I realized I could use macros to "read" the file system during compilation and generate the necessary require statements automatically.
1. Importing the File Tree (register_routes)
The first step is telling the compiler which files exist. I created a macro called register_routes.
This macro uses an interesting trick: executing system commands inside the compilation process. This is possible thanks to the command literal.
def `(command) : MacroId
Executes a system command and returns the output as a MacroId. Gives a compile-time error if the command failed to execute.
We can run a shell command at compile time and use the result in our code. By combining this with a command to list files in /src/routes/**/*.cr, we get:
macro register_routes # We execute a command to find all .cr files inside src/routes. # This happens BEFORE the final binary exists. # We use `crystal eval` here to utilize Crystal's Dir globbing capabilities from the shell. {% files = `crystal eval "Dir.glob(\\"src/routes/**/*.cr\\").map { |file| p file }"` %} # Iterate over the list of found files and inject # a static 'require' for each one. {% for file in files.lines.map(&.strip.gsub(/\"/, "")) %} require {{ file.gsub(/src\//, "./") }} {% end %} end
With this snippet, Crystal "sees" every file in our routes folder during compilation and includes them in the project.
2. Constructing the Route (define_handler)
Now that the files are imported, each file needs to know "who it is" and register its own route in the server (I am using Kemal for this example).
This is where the second macro comes in: define_handler. It is responsible for transforming the physical file path into a valid URL route. It performs the following tasks:
- Gets the current file path (
__FILE__). - Cleans the path (removes
src/routes, extensions, etc.). - Converts parameter syntax from
[id]to Kemal's:id. - Detects the HTTP verb (e.g., if the file ends in
.post.cror.get.cr).
require "kemal" macro define_handler(&block) # __FILE__ is a magic constant containing the current file's path route_path = __FILE__ .downcase .gsub(/.+\/src\/routes/, "") # Remove base path .gsub(/\/index\.cr$/, "") # "index.cr" is the root .gsub(/\.cr$/, "") # Remove extension .gsub(/\[(.*?)\]/, ":\\1") # Convert [param] to :param # Detect HTTP verb based on extension (e.g., file.post.cr) method = if match = /\.(?'method'get|post)$/.match(route_path) match[1] else "get" # Default to GET end # Clean the verb from the final URL path = route_path.gsub(/\.#{method}$/, "").gsub("index", "") # Register the route in Kemal Kemal::RouteHandler::INSTANCE.add_route(method.upcase, path) do |env| {{block.body}} end end
The Final Result
Thanks to these two macros, the developer experience becomes incredibly clean.
My src/main.cr file remains minimalist:
require "./macros" # Where I saved the previous macros register_routes Kemal.run
And now, creating routes is as simple as creating files, just like in Nuxt:
1. Basic Route (src/routes/hello.cr)
Generates:
GET /hello
define_handler do "Hello world from Crystal!" end
2. Dynamic Route (src/routes/users/[id].cr)
Generates:
GET /users/:id
define_handler do |env| id = env.params.url["id"] "User Profile #{id}" end
3. POST Route (src/routes/api/data.post.cr)
Generates:
POST /api/data
define_handler do "Receiving data..." end
Conclusion
Crystal continues to surprise me with its power and flexibility. By leveraging macros, we can replicate the high-level ergonomics of JavaScript frameworks while maintaining the type safety and raw performance of a compiled language.
Now I have the best of both worlds: the DX of Nuxt and the speed of Crystal.
This post was written with the help of Gemini.