Luma Web Server: Building Web Applications Made Simple

Luma Web Server: Building Web Applications Made Simple

February 9, 2026·
Luma Core Team

Luma now includes a built-in web server that lets you build HTTP applications with minimal boilerplate. True to Luma’s philosophy — handlers are just functions, types are clear and explicit, and you can go from zero to a running server in a few lines of code.

Hello World

fn hello(req: Request) -> Response {
    return text("Hello World!")
}

routes: [Route] = [get("/", hello)]

config: ServerConfig = ServerConfig {
    port: 3000,
    routes: routes,
}

server.start(config)

Run it:

luma run server.luma
# Luma server listening on :3000
curl http://localhost:3000/
# Hello World!

That’s it. No imports, no middleware setup, no boilerplate. Define a handler, create routes, start the server.

Core Concepts

Handlers

A handler is any function that takes a Request and returns a Response:

fn my_handler(req: Request) -> Response {
    return text("OK")
}

There’s no special interface to implement, no trait to satisfy. It’s just a function.

Routes

Routes bind HTTP methods and URL paths to handlers using helper functions:

get("/", index_handler)
post("/users", create_user)
put("/users/:id", update_user)
delete("/users/:id", delete_user)

Each returns a Route value that you collect into a list:

routes: [Route] = [
    get("/", index_handler),
    post("/users", create_user),
    put("/users/:id", update_user),
    delete("/users/:id", delete_user),
]

Server Configuration

The ServerConfig struct ties everything together:

config: ServerConfig = ServerConfig {
    port: 8080,
    routes: routes,
}

server.start(config)

server.start(config) blocks and listens for incoming HTTP requests. It prints a startup message and runs until the process is stopped.

Request

The Request struct gives you access to everything about the incoming HTTP request:

struct Request {
    method: str           // "GET", "POST", etc.
    path: str             // "/users/42"
    url: str              // "/users/42?sort=name"
    headers: {str: str}   // request headers
    query: {str: str}     // query parameters
    params: {str: str}    // path parameters from :param syntax
    body_bytes: [byte]    // raw request body
}

Accessing Request Data

Headers, query parameters, and path parameters are all maps — use standard map indexing:

fn my_handler(req: Request) -> Response {
    // Path parameter
    id: str = req.params["id"]

    // Query parameter: /search?q=luma
    search: str = req.query["q"]

    // Request header
    content_type: str = req.headers["Content-Type"]

    // HTTP method
    method: str = req.method

    return text("OK")
}

Response

The Response struct represents what you send back to the client:

struct Response {
    status: int           // HTTP status code
    headers: {str: str}   // response headers
    body: [byte]          // response body bytes
}

Response Helpers

Instead of building Response structs manually, use the built-in helpers:

text(body) — Plain Text

fn hello(req: Request) -> Response {
    return text("Hello World!")
}
// Content-Type: text/plain; charset=utf-8

html(body) — HTML

fn page(req: Request) -> Response {
    return html("<h1>Welcome to Luma!</h1>")
}
// Content-Type: text/html; charset=utf-8

Works naturally with string interpolation:

fn greet(req: Request) -> Response {
    name: str = req.params["name"]
    return html("<h1>Hello, ${name}!</h1>")
}

json(body) — JSON

fn status(req: Request) -> Response {
    return json({"status": "ok", "version": "1.0"})
}
// Content-Type: application/json
// {"status":"ok","version":"1.0"}

redirect(location) — Redirect (HTTP 302)

fn old_page(req: Request) -> Response {
    return redirect("/new-page")
}

file_download(path) — File Download

fn download(req: Request) -> Response {
    return file_download("reports/annual.pdf")
}
// Sets Content-Disposition: attachment; filename="annual.pdf"

Custom Responses

For full control, construct Response directly:

fn not_found(req: Request) -> Response {
    return Response {
        status: 404,
        body: "Page not found".to_bytes(),
    }
}

Dynamic Routes

Path parameters use colon syntax. Parameters are extracted automatically and available in req.params:

fn show_user(req: Request) -> Response {
    id: str = req.params["id"]
    return text("User ID: ${id}")
}

fn show_post(req: Request) -> Response {
    user: str = req.params["user"]
    post_id: str = req.params["id"]
    return text("User ${user}, Post ${post_id}")
}

routes: [Route] = [
    get("/users/:id", show_user),
    get("/users/:user/posts/:id", show_post),
]
curl http://localhost:8080/users/42
# User ID: 42

curl http://localhost:8080/users/alice/posts/7
# User alice, Post 7

Middleware

Middleware wraps handlers to add pre/post-processing. A middleware function receives the request and the next handler in the chain:

fn logger(req: Request, next: (Request) -> Response) -> Response {
    print("${req.method} ${req.path}")
    resp: Response = next(req)
    return resp
}

config: ServerConfig = ServerConfig {
    port: 3000,
    routes: routes,
    middleware: [logger],
}

Middleware is applied in order — the first middleware in the list is the outermost wrapper, executed first on the way in and last on the way out.

Error Handling

Set an error handler to catch panics in handlers and return a clean error response:

fn error_handler(err: Error, req: Request) -> Response {
    return json({"error": "Internal server error"})
}

config: ServerConfig = ServerConfig {
    port: 3000,
    routes: routes,
    on_error: error_handler,
}

If no on_error handler is set, panics will propagate normally.

Complete Example: A Simple API

fn index(req: Request) -> Response {
    return html("<h1>Welcome to Luma!</h1>")
}

fn greet(req: Request) -> Response {
    name: str = req.params["name"]
    return html("<h1>Hello, ${name}!</h1>")
}

fn api_status(req: Request) -> Response {
    return json({"status": "ok", "version": "1.0"})
}

fn create_user(req: Request) -> Response {
    return json({"status": "created"})
}

routes: [Route] = [
    get("/", index),
    get("/greet/:name", greet),
    get("/api/status", api_status),
    post("/api/users", create_user),
    delete("/api/users", create_user),
]

config: ServerConfig = ServerConfig {
    port: 8080,
    routes: routes,
}

server.start(config)
luma run api.luma
# Luma server listening on :8080

curl http://localhost:8080/
# <h1>Welcome to Luma!</h1>

curl http://localhost:8080/greet/World
# <h1>Hello, World!</h1>

curl http://localhost:8080/api/status
# {"status":"ok","version":"1.0"}

curl -X POST http://localhost:8080/api/users
# {"status":"created"}

Types Reference

Request

Field Type Description
method str HTTP method (GET, POST, etc.)
path str URL path
url str Full URL string
headers {str: str} Request headers
query {str: str} Query string parameters
params {str: str} Path parameters from :param routes
body_bytes [byte] Raw request body

Response

Field Type Description
status int HTTP status code
headers {str: str} Response headers
body [byte] Response body bytes

Route Helpers

Function Description
get(path, handler) Create a GET route
post(path, handler) Create a POST route
put(path, handler) Create a PUT route
delete(path, handler) Create a DELETE route

Response Helpers

Function Description
text(body) Plain text response (200)
html(body) HTML response (200)
json(body) JSON response (200)
redirect(location) Redirect response (302)
file_download(path) File download response

ServerConfig

Field Type Description
port int Port to listen on
routes [Route] List of route definitions
middleware [Middleware] Middleware chain (optional)
on_error ?ErrorHandler Error handler (optional)

Design Philosophy

The web server was designed around three principles:

  1. Minimal boilerplate — A hello world server is 12 lines. No imports, no class hierarchies, no configuration files.

  2. Handlers are just functions — No interfaces to implement, no decorators, no magic. A handler takes a Request and returns a Response. That’s it.

  3. Clear and explicit types — Request, Response, Route, and ServerConfig are plain structs. You can inspect them, construct them, and pass them around like any other value.

Under the hood, Luma compiles to Go and uses Go’s net/http package. You get the performance and reliability of Go’s battle-tested HTTP stack with the simplicity of Luma’s syntax.

Last updated on