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.
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>© 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:
urlEncodeandurlDecodefor 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
getSessionhelper 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.