Guides menu
Guide

Build a REST API

A step-by-step tutorial: build a small JSON REST API with routes, middleware, and security using serez-http, then ship it as a Docker image with serez-apipack.

What you'll build: a tasks API with GET/POST routes, route parameters, logging and auth middleware, rate limiting, then a deployable Docker container.

Step 1 — Set up

mkdir tasks-api
cd tasks-api
sz init --y
sz install serez-http

sz init --y creates a serez.json with a dev script (sz index.sz), so we'll start the server with sz run dev. serez-http declares the Socket and Time permissions it needs in its own modules, so your app doesn't have to. Create index.sz and create the app:

import "serez-http"

const app = new App()

Step 2 — Your first route

A route is an HTTP verb, a path, and a handler that receives req and res. Send JSON with res.json():

app.GET("/", fn(req, res) {
    // dicts are typed: <string, any> with ({"key", value}) entries
    let r <string, any> = ({"message", "tasks API"}, {"version", "1.0"})
    res.json(r)
})

app.listen(3000, fn() {
    out "listening on http://127.0.0.1:3000"
})

Run it with the dev script and hit it from another terminal:

sz run dev
# elsewhere:
curl http://127.0.0.1:3000/
Note: get and use are reserved words in Serez Code, so the methods are GET/POST/PUT/delete and middleware is registered with addMw.

Step 3 — Route parameters and a data store

Use :param segments and read them from req["params"]. We'll keep tasks in a simple in-memory array:

let tasks = ["buy milk", "write docs", "ship release"]

// List all tasks
app.GET("/tasks", fn(req, res) {
    let r <string, any> = ({"tasks", tasks}, {"count", tasks.length()})
    res.json(r)
})

// Get one task by index: /tasks/0
app.GET("/tasks/:id", fn(req, res) {
    let id = parseInt(req["params"]["id"])
    if (id < 0 || id >= tasks.length()) {
        let e <string, any> = ({"error", "no such task"})
        res.status(404).json(e)
    } else {
        let r <string, any> = ({"id", id}, {"task", tasks[id]})
        res.json(r)
    }
})

Step 4 — Handle POST with a JSON body

The raw body is in req["body"]. Parse it with JSON.parse:

app.POST("/tasks", fn(req, res) {
    let body = JSON.parse(req["body"])
    let title = body["title"]
    if (title == null) {
        let e <string, any> = ({"error", "title is required"})
        res.status(400).json(e)
    } else {
        tasks.push(title)
        let r <string, any> = ({"added", title}, {"count", tasks.length()})
        res.status(201).json(r)
    }
})
curl -X POST http://127.0.0.1:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "review PR"}'

Step 5 — Middleware: logging and auth

Middleware runs before handlers. Register it with addMw; call next() to continue, or send a response to stop the chain. Order matters — register middleware before listen:

// Log every request
app.addMw(fn(req, res, next) {
    out req["method"] + " " + req["path"]
    next()
})

// Require a token on write routes
app.addMw(fn(req, res, next) {
    if (req["method"] == "POST" && req["authorization"] == "") {
        let e <string, any> = ({"error", "unauthorized"})
        res.status(401).json(e)
    } else {
        next()
    }
})

Step 6 — Security: rate limiting and CORS

Throttle abusive clients with the built-in per-route rate limiter, and control which origins may call your API with app.cors. Both are native App methods — the old corsMiddleware / _rateLimit helpers are deprecated:

// Max 100 requests per minute on /tasks — exceeding it answers 429 automatically
app.rateLimitRoute("/tasks", 100, 60)

// CORS headers on every response + automatic preflight (OPTIONS → 204)
app.cors("https://my-frontend.com", "GET, POST, PUT, DELETE", "Content-Type, Authorization")

// You can also set a global header on all responses
app.header("X-Powered-By", "serez-http")
The limit is per route and server (not per client IP — the core's Socket does not expose the peer address yet).

Step 7 — Handle errors

Register a global error handler so a thrown exception in a handler returns a clean 500 instead of crashing the server:

app.error(fn(err, req, res) {
    out "error: " + err
    let e <string, any> = ({"error", "internal server error"})
    res.status(500).json(e)
})

Step 8 — Deploy with serez-apipack

serez-apipack packages the project into a self-contained Docker image — the host only needs Docker, not Serez Code. Your serez.json already has a dev script from sz init; declare the dependency and the full dev/build script pair:

{
  "name": "tasks-api",
  "version": "1.0.0",
  "main": "index.sz",
  "scripts": {
    "dev": "sz index.sz",
    "build": "sz run apipack tag=tasks-api:1.0.0"
  },
  "dependencies": {
    "serez-http": "1.0.4",
    "serez-apipack": "1.1.4"
  }
}

Now the workflow is two commands: sz run dev to develop locally, and sz run build to produce the Docker image. Install the packer and build:

sz install                         # installs serez-http + serez-apipack from serez.json

sz run dev                         # local development server
sz run build                       # → runs the apipack packer, builds tasks-api:1.0.0

# you can also invoke the packer directly (options are key=value, no dashes):
sz run apipack port=8080 tag=tasks-api:latest

Run the container — it's reachable from outside the host:

docker run -p 3000:3000 tasks-api:1.0.0
How it works: Serez Code binds to 127.0.0.1, so apipack adds a socat proxy inside the image that forwards 0.0.0.0:PORT → the app. You write a local server; apipack makes it reachable.

Next steps