Your First Real App: A REST API in 80 Lines of Luma
Over the past weeks, Luma has gained a web server, JSON support, file handling, and a module system. Each of those features made sense on its own. But the real test of a language isn’t whether individual features work - it’s whether they work together, in the kind of code people actually write.
So we built a complete REST API. Not a toy. Not a hello-world with extra steps. A multi-file, persistent, CRUD application that manages users - with GET, POST, PUT, and DELETE endpoints, JSON request and response bodies, auto-assigned IDs, and data stored on disk.
The entire thing fits in about 80 lines across two files. No external libraries. No framework. No configuration files.
The shape of the app
The application lives in three files:
main.luma — routes and server startup
handlers.luma — request handlers and data access
data.json — persistent storage (auto-created if missing)That’s it. Two Luma source files and a JSON file that the app creates for you. Let’s walk through them.
The entry point
Here is main.luma in its entirety:
handlers = import "handlers"
routes: [Route] = [
get("/users", handlers.list_users),
get("/users/:id", handlers.get_user),
post("/users", handlers.create_user),
put("/users/:id", handlers.update_user),
delete("/users/:id", handlers.delete_user),
]
config: ServerConfig = ServerConfig {
port: 3000,
routes: routes,
}
print("User Management API")
print("Endpoints:")
print(" GET /users")
print(" GET /users/:id")
print(" POST /users")
print(" PUT /users/:id")
print(" DELETE /users/:id")
server.start(config)Read it once and you understand the whole application. Five routes, each pointing to a handler function from an imported module. A server config. A startup message. That’s the entire entry point.
There’s nothing to decode here. No middleware chain to trace. No dependency injection to configure. The routing table is a list. The handlers are functions. The server starts.
The handlers
The real work lives in handlers.luma. It starts with three helper functions:
fn load_users() -> any {
exists: bool = file("data.json").exists()
if !exists {
file("data.json").write("[]")
}
content: str = file("data.json").read()
return json.decode(content)
}
fn save_users(users: any) {
file("data.json").write(json.encode(users))
}
fn next_id(users: any) -> int {
max_id: int = 0
users.walk(user) -> {
uid: int = user["id"].to_str().to_int(0)
if uid > max_id {
max_id = uid
}
}
return max_id + 1
}These three functions are the entire persistence layer. load_users reads from disk and auto-creates the data file if it doesn’t exist. save_users writes back. next_id finds the highest existing ID and returns the next one.
No ORM, no database driver, no migration scripts. For a prototype or a small tool, this is exactly enough.
Notice that these helpers are fn, not pub fn. They’re private to the handlers module - used internally but not exposed. The public API is just the five handler functions.
Now look at how simple a handler is:
pub fn list_users(req: Request) -> Response {
users: any = load_users()
return json(users)
}Two lines. Load the data, return it as JSON. That’s a complete, working API endpoint.
The more interesting handlers show how naturally Luma’s features compose. Here’s the GET endpoint that finds a user by ID:
pub fn get_user(req: Request) -> Response {
id: str = req.params["id"]
users: any = load_users()
found: any = nil
users.walk(user) -> {
if user["id"].to_str() == id {
found = user
}
}
if found == nil {
return json({"error": "user not found", "id": id})
}
return json(found)
}The path parameter comes from req.params["id"] - pulled straight from the :id in the route definition. The search is a walk over the list. The error response is a JSON map literal written inline.
Nothing here is surprising. And that’s the point.
Creating, updating, deleting
The remaining handlers follow the same pattern. Create reads the request body, assigns an ID automatically, appends to the list, and saves:
pub fn create_user(req: Request) -> Response {
body: any = json.decode(req.body)
users: any = load_users()
new_id: int = next_id(users)
result: [any] = []
users.walk(user) -> {
result.add(user)
}
new_user: any = {"id": new_id, "name": body["name"], "age": body["age"]}
result.add(new_user)
save_users(result)
return json({"status": "created", "user": new_user})
}The caller sends {"name": "Charlie", "age": 35} and gets back {"status": "created", "user": {"id": 3, "name": "Charlie", "age": 35}}. The server handles the ID assignment — exactly how a real API should work.
Update finds the user by ID and modifies them in place:
pub fn update_user(req: Request) -> Response {
id: str = req.params["id"]
body: any = json.decode(req.body)
users: any = load_users()
found: bool = false
users.walk(user) -> {
if user["id"].to_str() == id {
user["name"] = body["name"]
user["age"] = body["age"]
found = true
}
}
if !found {
return json({"error": "user not found", "id": id})
}
save_users(users)
return json({"status": "updated", "id": id})
}Delete filters the user out:
pub fn delete_user(req: Request) -> Response {
id: str = req.params["id"]
users: any = load_users()
result: [any] = []
users.walk(user) -> {
if user["id"].to_str() != id {
result.add(user)
}
}
save_users(result)
return json({"status": "deleted", "id": id})
}Every handler follows the same rhythm: load, transform, save, respond. There’s no boilerplate between you and the logic.
Running it
luma run main.lumaUser Management API
Endpoints:
GET /users
GET /users/:id
POST /users
PUT /users/:id
DELETE /users/:id
Luma server listening on :3000Then, from another terminal:
# List all users (starts empty if no data.json exists)
curl localhost:3000/users
# []
# Create users — IDs are assigned automatically
curl -X POST localhost:3000/users -d '{"name":"Alice","age":30}'
# {"status":"created","user":{"age":30,"id":1,"name":"Alice"}}
curl -X POST localhost:3000/users -d '{"name":"Bob","age":25}'
# {"status":"created","user":{"age":25,"id":2,"name":"Bob"}}
# Get a user by ID
curl localhost:3000/users/1
# {"age":30,"id":1,"name":"Alice"}
# Update a user
curl -X PUT localhost:3000/users/1 -d '{"name":"Alice","age":31}'
# {"id":"1","status":"updated"}
# Delete a user
curl -X DELETE localhost:3000/users/2
# {"id":"2","status":"deleted"}
# Verify changes persisted
curl localhost:3000/users
# [{"age":31,"id":1,"name":"Alice"}]The data persists between requests because it’s written to data.json on every mutation. Restart the server and the changes are still there. Delete data.json and the app recreates it on the next request — starting fresh with an empty list.
What’s actually happening here
Step back for a moment and consider what this small application exercises:
- Web server with route parameters, all five HTTP methods, JSON responses
- JSON decoding of request bodies, encoding of response bodies, reading and writing data files
- File I/O for persistence, including existence checks and auto-creation
- Modules with
import,pub fnfor the public API, privatefnfor internal helpers - Auto-incrementing IDs computed from existing data
- List operations with
walk,add, and inline filtering - Map access with bracket notation on decoded JSON data
- Struct literals for server configuration and inline error responses
All of these features compose without friction. The JSON that comes off disk is the same type as the JSON that comes from a request body. The file you write is the same file abstraction you use everywhere else. The handlers are plain functions with typed signatures.
Why this matters
There’s a certain kind of application that comes up constantly: a small HTTP service that stores and retrieves data. A prototype. An internal tool. A weekend project that might grow into something real.
In most languages, getting to this point means choosing a web framework, a JSON library, a persistence strategy, and figuring out how they all wire together. The first hour is spent on scaffolding, not on the problem.
In Luma, you open two files and start writing handlers. The language provides the web server, the JSON support, and the file access. The module system gives you clean separation when you want it. And because there’s no framework - just functions, types, and a server - there’s nothing to configure, update, or work around.
This makes Luma particularly well-suited for rapid API prototyping. You can go from an idea to a running, testable endpoint in minutes. The code is short enough to fit on a screen, clear enough to explain to someone who’s never seen Luma, and real enough to actually use.
Honest about the edges
Building this application also revealed real limitations that we’ve been addressing:
- Private helper functions in imported modules needed compiler fixes to work correctly
- Mutating map entries on decoded JSON data required runtime improvements
- List operations on dynamically-typed data needed new runtime variants
We found these issues precisely because we built a real application, not a synthetic test. Each one has been fixed, and the fixes make every Luma program more robust.
That’s the value of building real things early. A real application isn’t just a showcase. It’s a stress test.
What’s next
This application proves that Luma’s core features work together in practice. The web server, JSON, file handling, and module system aren’t just individual capabilities anymore - they’re a coherent toolkit for building real applications.
We’re continuing to build more features and find more edges. But with this milestone, something important has shifted: Luma isn’t just a language with interesting ideas anymore. It’s a language you can build things with.