Web framework for Zig

Build fast, type-safe web services with Horizon.

Horizon is a modern web framework for Zig focused on simplicity, performance, and extensibility. It provides an HTTP server, routing, middleware, sessions, and template rendering – all with Zig’s strong compile-time guarantees.

Requires Zig 0.15.2+ PCRE2 for regex routing ZTS for templates

Overview

Horizon is a web framework written in Zig. It aims to keep the core small and predictable while giving you the tools you need to build HTTP APIs and web applications:

  • Simplicity: Straightforward APIs and clear control flow.
  • Performance: Built on Zig’s low-level control and optimizations.
  • Extensibility: Composable middleware chains and pluggable session backends.
  • Type safety: Strong compile-time guarantees backed by Zig’s type system.

Key Features

  • HTTP Server: Lightweight server built on Zig’s standard library.
  • Routing: Method-based routing with path parameters and PCRE2-based regex constraints.
  • Request & Response: Helpers for headers, query params, JSON/HTML/text responses, and file streaming.
  • Middleware: Global and route-specific middleware chains with built-in middlewares (logging, CORS, auth, error handling, static files).
  • Sessions: Cookie-based session management with memory and Redis backends.
  • Templates: ZTS (Zig Templates made Simple) integration for HTML rendering.

Requirements

  • Zig: 0.15.2 or later.
  • PCRE2 (`libpcre2-8`): For regex-based routing.
  • ZTS (Zig Template Strings): For server-side HTML templates.
  • Docker & Docker Compose (optional): For a preconfigured development environment.

Getting Started

This quickstart shows how to set up Horizon as a dependency and run a minimal HTTP server. For a fully configured example including frontend and database, see the sample application below.

1. Add Horizon as a dependency

Fetch Horizon using Zig's package manager:

zig fetch --save-exact=horizon https://github.com/HARMONICOM/horizon/archive/refs/tags/v0.1.7.tar.gz

2. Configure build.zig

Import the Horizon module and link it into your executable:

const horizon_dep = b.dependency("horizon", .{
    .target = target,
    .optimize = optimize,
});

const exe = b.addExecutable(.{
    .name = "app",
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("horizon", horizon_dep.module("horizon"));
b.installArtifact(exe);

3. Install PCRE2

PCRE2 is required for regex routing:

# Debian/Ubuntu
sudo apt-get install libpcre2-dev

# macOS (Homebrew)
brew install pcre2

# Dockerfile
RUN apt-get update && apt-get install -y libpcre2-dev

4. Minimal Horizon server

Here is a small but complete Horizon application:

const std = @import("std");
const net = std.net;
const horizon = @import("horizon");

const Server = horizon.Server;
const Context = horizon.Context;
const Errors = horizon.Errors;

fn homeHandler(context: *Context) Errors.Horizon!void {
    try context.response.html("<h1>Hello Horizon!</h1>");
}

fn apiHandler(context: *Context) Errors.Horizon!void {
    const name = context.request.getQuery("name") orelse "World";
    const json = try std.fmt.allocPrint(
        context.allocator,
        "{{\"message\":\"Hello, {s}!\"}}",
        .{ name },
    );
    defer context.allocator.free(json);
    try context.response.json(json);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const address = try net.Address.resolveIp("0.0.0.0", 5000);
    var srv = Server.init(allocator, address);
    defer srv.deinit();

    try srv.router.get("/", homeHandler);
    try srv.router.get("/api/hello", apiHandler);

    // Optional: print the route table on startup
    // srv.show_routes_on_startup = true;

    std.debug.print("Server listening on http://0.0.0.0:5000\n", .{});
    try srv.listen();
}

Build and run your server with:

zig build run

Then open http://localhost:5000/ or http://localhost:5000/api/hello?name=Zig.

Routing

Horizon’s router lets you define HTTP routes with method-aware matching, path parameters, and PCRE2-based constraints. Routes are attached to the server’s internal router.

Basic routes

try srv.router.get("/", homeHandler);
try srv.router.post("/users", createUserHandler);
try srv.router.put("/users/:id", updateUserHandler);
try srv.router.delete("/users/:id", deleteUserHandler);

Path parameters

Use :name segments to capture parameters from the path:

try srv.router.get("/users/:id", getUserHandler);

fn getUserHandler(context: *Context) Errors.Horizon!void {
    if (context.request.getParam("id")) |id| {
        try context.response.text(id);
    } else {
        context.response.setStatus(.bad_request);
        try context.response.json("{\"error\":\"ID not found\"}");
    }
}

Regex-constrained parameters

Attach a PCRE2 pattern directly to the parameter:

// Only numbers
try srv.router.get("/users/:id([0-9]+)", getUserHandler);

// Letters only
try srv.router.get("/category/:name([a-zA-Z]+)", getCategoryHandler);

If the pattern does not match, the request is treated as a 404.

Route groups with mount

Group related routes under a common prefix:

try srv.router.mount("/api", .{
    .{ "GET",  "/users", listUsers },
    .{ "POST", "/users", createUser },
    .{ "GET",  "/posts", listPosts },
});

Mounting routes from modules

// routes/api.zig
const horizon = @import("horizon");
const Context = horizon.Context;
const Errors = horizon.Errors;

fn usersHandler(context: *Context) Errors.Horizon!void {
    try context.response.text("API: Users");
}

pub const routes = .{
    .{ "GET", "/users", usersHandler },
};
// main.zig
const api_routes = @import("routes/api.zig");
try srv.router.mount("/api", api_routes);

Debugging routes

srv.show_routes_on_startup = true;
try srv.listen();

// or:
srv.router.printRoutes();

Request & Response

Each handler in Horizon receives a *Context which exposes a Request and a Response object. You use these to read request data and build responses.

Reading headers and query parameters

fn handler(context: *Context) Errors.Horizon!void {
    const method = context.request.method;
    const uri = context.request.uri;

    const auth = context.request.getHeader("Authorization") orelse "none";
    const page = context.request.getQuery("page") orelse "1";
    const limit = context.request.getQuery("limit") orelse "10";

    _ = method;
    _ = uri;
    _ = auth;
    _ = page;
    _ = limit;
}

JSON, HTML, and text responses

try context.response.json("{\"message\":\"Hello\",\"status\":\"ok\"}");
// Content-Type: application/json

try context.response.html(
    \\<!DOCTYPE html>
    \\<html>
    \\<head><title>Horizon</title></head>
    \\<body><h1>Hello from Horizon</h1></body>
    \\</html>
);
// Content-Type: text/html; charset=utf-8

try context.response.text("Hello, World!");
// Content-Type: text/plain; charset=utf-8

Custom headers and status codes

context.response.setStatus(.created);
try context.response.setHeader("X-Request-Id", "12345");
try context.response.setHeader("Cache-Control", "no-store");
try context.response.text("Created");

Redirects

Horizon supports both temporary (302) and permanent (301) redirects:

// Temporary redirect (302 Found)
try context.response.redirect("https://example.com/new-location");

// Permanent redirect (301 Moved Permanently)
try context.response.redirectPermanent("https://example.com/new-location");

Streaming files

fn downloadHandler(context: *Context) Errors.Horizon!void {
    const file_path = "public/downloads/file.pdf";
    const file_stat = try std.fs.cwd().statFile(file_path);

    context.response.setStatus(.ok);
    try context.response.setHeader("Content-Type", "application/pdf");
    try context.response.setHeader(
        "Content-Disposition",
        "attachment; filename=\\"file.pdf\\"",
    );

    try context.response.streamFile(file_path, file_stat.size);
}

URL encoding and decoding

Horizon provides utility functions for URL encoding and decoding:

const horizon = @import("horizon");

// Encode a string for use in URLs
const encoded = try horizon.urlEncode(allocator, "Hello World!");
defer allocator.free(encoded);
// Result: "Hello%20World%21"

// Decode a URL-encoded string
const decoded = try horizon.urlDecode(allocator, "Hello%20World%21");
defer allocator.free(decoded);
// Result: "Hello World!"

Middleware

Middleware in Horizon forms a chain that wraps your handlers. Each middleware can inspect or modify the request/response and decide whether to continue the chain.

Global middleware

const LoggingMiddleware = horizon.LoggingMiddleware;
const CorsMiddleware = horizon.CorsMiddleware;

var srv = Server.init(allocator, address);
defer srv.deinit();

const logging = LoggingMiddleware.init();
try srv.router.middlewares.use(&logging);

const cors = CorsMiddleware.initWithConfig(.{
    .allow_origin = "*",
    .allow_methods = "GET, POST, PUT, DELETE, OPTIONS",
    .allow_headers = "Content-Type, Authorization",
});
try srv.router.middlewares.use(&cors);

Route-specific middleware

const BearerAuthMiddleware = horizon.BearerAuthMiddleware;
          const MiddlewareChain = horizon.Middleware.Chain;

          var protected_middlewares = MiddlewareChain.init(allocator);
          defer protected_middlewares.deinit();

          const bearer_auth = BearerAuthMiddleware.init("secret-token");
          try protected_middlewares.use(&bearer_auth);

          try srv.router.getWithMiddleware("/api/protected", protectedHandler, &protected_middlewares);

Static file middleware

const StaticMiddleware = horizon.StaticMiddleware;

// Serve files from "./public" under "/static"
var static_mw = StaticMiddleware.init("public");
try srv.router.middlewares.use(&static_mw);

Custom middleware

pub const CustomMiddleware = struct {
    const Self = @This();

    prefix: []const u8,
    enabled: bool,

    pub fn init(prefix: []const u8) Self {
        return .{ .prefix = prefix, .enabled = true };
    }

    pub fn middleware(
        self: *const Self,
        allocator: std.mem.Allocator,
        req: *horizon.Request,
        res: *horizon.Response,
        ctx: *horizon.Middleware.Context,
    ) horizon.Errors.Horizon!void {
        _ = allocator;
        if (self.enabled) {
            std.debug.print("{s}: {s}\n", .{ self.prefix, req.uri });
        }
        try ctx.next(allocator, req, res);
    }
};

Sessions

Horizon’s session system lets you store per-user state on the server, identified by a secure cookie. You can choose between in-memory and Redis-backed storage.

Session middleware setup

const SessionStore = horizon.SessionStore;
const SessionMiddleware = horizon.SessionMiddleware;

var session_store = SessionStore.init(allocator);
defer session_store.deinit();

var srv = Server.init(allocator, address);
defer srv.deinit();

const session_middleware = SessionMiddleware.init(&session_store);
try srv.router.middlewares.use(&session_middleware);

Using sessions in handlers

fn loginHandler(context: *Context) Errors.Horizon!void {
    if (SessionMiddleware.getSession(context.request)) |session| {
        try session.set("user_id", "123");
        try session.set("username", "alice");
        try session.set("logged_in", "true");
        try context.response.json("{\"status\":\"ok\",\"message\":\"Logged in\"}");
    } else {
        context.response.setStatus(.internal_server_error);
        try context.response.json("{\"error\":\"Failed to create session\"}");
    }
}

Redis-backed sessions

const RedisBackend = horizon.RedisBackend;

var redis_backend = try RedisBackend.initWithConfig(allocator, .{
    .host = "127.0.0.1",
    .port = 6379,
    .prefix = "horizon:session:",
    .default_ttl = 3600,
});
defer redis_backend.deinit();

var session_store = SessionStore.initWithBackend(allocator, redis_backend.backend());
defer session_store.deinit();

Templates

Horizon integrates with ZTS (Zig Templates made Simple) to render HTML at compile time. Templates are section-based and typically stored under a views/ directory.

Template sections

<!DOCTYPE html>
<html>
<head><title>My Page</title></head>
<body>
.header
<header>
  <h1>Welcome</h1>
</header>
.content
<main>
  <p>Main content here</p>
</main>
.footer
<footer>
  <p>&copy; 2025</p>
</footer>
</body>
</html>

Rendering templates

const welcome_template = @embedFile("views/welcome.html");

fn handleWelcome(context: *Context) Errors.Horizon!void {
    try context.response.renderHeader(welcome_template, .{"Welcome to Horizon!"});
}

Concatenating sections

var renderer = try context.response.renderMultiple(welcome_template);
_ = try renderer.writeHeader(.{});
_ = try renderer.write("header", .{});
_ = try renderer.write("content", .{});
_ = try renderer.write("footer", .{});

High-level API Reference

This section summarizes the main types in Horizon. For deeper explanations and examples, refer to the dedicated guide sections above.

Server

  • Fields: allocator, router, address, show_routes_on_startup, max_threads.
  • Key methods: init, deinit, listen.

Router

  • Key methods: get, post, put, delete, mount, mountWithMiddleware, printRoutes.

Context

Bundles together the allocator, request, response, router, and server for each handler:

pub const Context = struct {
    allocator: std.mem.Allocator,
    request: *Request,
    response: *Response,
    router: *Router,
    server: *Server,
};

Request & Response

  • Request: methods like getHeader, getQuery, getParam.
  • Response: methods like setStatus, setHeader, json, html, text, streamFile, redirect, redirectPermanent.
  • URL Utilities: urlEncode and urlDecode for percent encoding/decoding.

Sessions & Middleware

  • Session: set, get, remove, isValid, setExpires.
  • SessionStore: create, get, save, remove, cleanup.
  • SessionMiddleware: attaches sessions to requests via cookies. Use getSession helper to retrieve session from request.
  • MiddlewareChain: manages the execution of middleware in order.

URL Utilities

  • urlEncode: Percent-encode a string for use in URLs.
  • urlDecode: Decode a percent-encoded string.

Project Structure

The Horizon repository itself is organized as follows:

horizon/
├── src/
│   ├── horizon.zig           # Main module export
│   └── horizon/
│       ├── server.zig        # HTTP server
│       ├── router.zig        # Router
│       ├── request.zig       # Request handling
│       ├── response.zig      # Response handling
│       ├── context.zig       # Unified context
│       ├── middleware.zig    # Middleware chain
│       ├── middlewares/      # Built-in middlewares
│       ├── libs/             # External library bindings (pcre2)
│       └── utils/            # Utilities (errors, redis client)
├── tests/                    # Test suite
└── documents/                # User documentation (this guide is based on it)

Testing

Horizon ships with a comprehensive test suite. You can run it using the provided Makefile:

# Run all tests
make zig build test

# Run tests with filter
make zig build test -- --test-filter router

Sample Application: horizon_sample

The horizon_sample repository demonstrates how to structure a real-world Horizon application, including a Zig backend, React-based frontend, and Dockerized infrastructure.

Quick start

make up
# Application will be available at http://localhost:5000/

Sample project layout

horizon_sample/
├── src/
│   ├── main.zig         # Application entry point
│   ├── root.zig         # Root module wiring
│   ├── libs/            # Shared libraries (e.g., DB helper)
│   ├── models/          # Domain models
│   ├── routes/          # Route handlers (admin, index, etc.)
│   └── views/           # ZTS templates
├── frontend/            # Sample React frontend
├── static/              # Raw static files served by Horizon
├── infra/               # Docker files for app, cache (Redis), and DB
└── build.zig            # Zig build configuration

Use this sample as a reference when designing your own Horizon-based applications: separate routes into modules, keep templates in views/, and rely on middleware for cross-cutting concerns like logging, CORS, auth, and sessions.