# Zig HTTP Client and Server Examples for ESP-IDF: A Complete Implementation Guide

> Explore Zig HTTP client and server examples for ESP-IDF. This guide offers idiomatic Zig wrappers for ESP-IDF HTTP components, including URI handlers and synchronous requests.

- Repository: [Matheus C. França/zig-esp-idf-sample](https://github.com/kassane/zig-esp-idf-sample)
- Tags: examples
- Published: 2026-03-05

---

**The zig-esp-idf-sample repository provides idiomatic Zig wrappers around ESP-IDF's HTTP components, implementing both an HTTP server with URI handlers and an HTTP client with synchronous request capabilities.**

The zig-esp-idf-sample repository serves as the definitive reference for implementing Zig HTTP client and server examples for ESP-IDF microcontrollers on the ESP32 platform. By combining Zig's error handling and compile-time features with ESP-IDF's battle-tested C APIs, developers can build robust embedded web services and REST clients without sacrificing memory safety or performance.

## Architecture Overview

The codebase abstracts ESP-IDF's HTTP components through thin wrappers defined in `imports/http.zig`. These wrappers convert low-level C error codes into Zig error unions while preserving the full functionality of the underlying `esp_http_server` and `esp_http_client` libraries.

### HTTP Server Architecture

The server implementation exposes `idf.http.Server` for lifecycle management and `idf.http.Server.Response` for output handling. These wrap the `esp_httpd_*` API family, allowing Zig code to register URI handlers and send responses using native error handling constructs.

### HTTP Client Architecture

The client side encapsulates `esp_http_client_*` functions within the `idf.http.Client` struct. All methods return Zig errors via `idf.err.espCheckError`, which translates ESP-IDF error codes (like `ESP_FAIL`) into catchable Zig errors.

## Building an HTTP Server in Zig

The complete server implementation resides in `main/examples/http-server.zig`, demonstrating Wi-Fi initialization, server configuration, and URI handler registration.

### Configuring the Server

Server initialization requires populating a `sys.httpd_config_t` struct to define task parameters and resource limits:

```zig
var config = std.mem.zeroes(sys.httpd_config_t);
config.server_port = 80;
config.stack_size = 4096;
config.task_priority = 5;
config.max_uri_handlers = 8;

```

### Registering URI Handlers

Handlers must use the C calling convention and accept a `sys.httpd_req_t` pointer. The example implements two endpoints: a static HTML root page and a JSON API:

```zig
export fn handleRoot(req: [*c]sys.httpd_req_t) callconv(.c) sys.esp_err_t {
    idf.http.Server.Response.sendStr(req, index_html) catch |err| {
        log.err("sendStr: {s}", .{@errorName(err)});
        return sys.ESP_FAIL;
    };
    return sys.ESP_OK;
}

export fn handleApiHello(req: [*c]sys.httpd_req_t) callconv(.c) sys.esp_err_t {
    idf.http.Server.Response.setType(req, "application/json") catch {};
    idf.http.Server.Response.sendStr(req,
        "{\"message\":\"Hello from Zig!\"}") catch |err| {
        log.err("sendStr: {s}", .{@errorName(err)});
        return sys.ESP_FAIL;
    };
    return sys.ESP_OK;
}

```

### Starting the Server

The `startHttpServer()` function initializes the server and registers endpoints using `idf.http.Server.registerUri()`:

```zig
fn startHttpServer() !void {
    var config = std.mem.zeroes(sys.httpd_config_t);
    config.server_port = 80;
    config.stack_size = 4096;
    config.task_priority = 5;
    config.max_uri_handlers = 8;

    const server = try idf.http.Server.start(&config);

    const root_uri = sys.httpd_uri_t{
        .uri = "/",
        .method = sys.HTTP_GET,
        .handler = &handleRoot,
        .user_ctx = null,
    };
    try idf.http.Server.registerUri(server, &root_uri);

    const api_uri = sys.httpd_uri_t{
        .uri = "/api/hello",
        .method = sys.HTTP_GET,
        .handler = &handleApiHello,
        .user_ctx = null,
    };
    try idf.http.Server.registerUri(server, &api_uri);

    log.info("HTTP server started on port 80", .{});
}

```

The server is invoked from `app_main()` in `main/app.zig` after Wi-Fi connection establishment (lines 78–122 of `http-server.zig`).

## Implementing an HTTP Client

The client implementation in `imports/http.zig` (lines 165–236) provides synchronous request capabilities with streaming support.

### Client Initialization and Configuration

Initialize the client with an `esp_http_client_config_t` struct containing the target URL and optional event handlers:

```zig
pub const Client = struct {
    handle: sys.esp_http_client_handle_t = null,

    pub fn init(cfg: *const sys.esp_http_client_config_t) Client {
        return .{ .handle = sys.esp_http_client_init(cfg) };
    }

    pub fn setUrl(self: *Client, url: [*:0]const u8) !void {
        try errors.espCheckError(sys.esp_http_client_set_url(self.handle, url));
    }
};

```

### Performing Requests and Reading Responses

The client supports synchronous execution via `perform()` followed by status code inspection and body reading:

```zig
pub fn perform(self: *Client) !void {
    try errors.espCheckError(sys.esp_http_client_perform(self.handle));
}

pub fn statusCode(self: *Client) u32 {
    return sys.esp_http_client_get_status_code(self.handle);
}

pub fn read(self: *Client, buf: []u8) usize {
    return @intCast(sys.esp_http_client_read(self.handle, buf.ptr, @intCast(buf.len)));
}

pub fn deinit(self: *Client) !void {
    try errors.espCheckError(sys.esp_http_client_cleanup(self.handle));
}

```

### Complete Client Usage Example

A typical GET request implementation looks like this:

```zig
pub fn fetchExample() !void {
    var cfg = sys.esp_http_client_config_t{
        .url = "http://example.com",
        .event_handler = null,
        .user_data = null,
        .disable_auto_redirect = false,
    };
    var client = http.Client.init(&cfg);
    defer client.deinit() catch {};

    try client.setUrl("http://example.com");
    try client.perform();

    const status = client.statusCode();
    std.debug.print("Status = {}\n", .{status});

    var buf: [1024]u8 = undefined;
    const len = client.read(&buf);
    std.debug.print("Body ({}) = {s}\n", .{ len, buf[0..len] });
}

```

## Advanced Pattern: HTTP Proxy Implementation

You can combine both components to create a proxy endpoint that fetches remote data within a server handler:

```zig
export fn handleProxy(req: [*c]sys.httpd_req_t) callconv(.c) sys.esp_err_t {
    var cfg = sys.esp_http_client_config_t{
        .url = "http://worldtimeapi.org/api/ip",
        .event_handler = null,
        .user_data = null,
        .disable_auto_redirect = false,
    };
    var client = idf.http.Client.init(&cfg);
    defer client.deinit() catch {};

    client.perform() catch |e| {
        idf.http.Server.Response.send500(req) catch {};
        return sys.ESP_FAIL;
    };

    var body: [512]u8 = undefined;
    const n = client.read(&body);
    try idf.http.Server.Response.setType(req, "application/json");
    try idf.http.Server.Response.send(req, body[0..n]);

    return sys.ESP_OK;
}

```

Register this handler like any other URI to transform the ESP32 into a lightweight HTTP proxy.

## Project Structure and Key Files

Understanding the repository layout is essential for extending these examples:

- `main/examples/http-server.zig` – Full server implementation with Wi-Fi initialization and URI handlers
- `imports/http.zig` – Zig wrappers for both `idf.http.Server` and `idf.http.Client`
- `imports/wifi.zig` – Wi-Fi convenience helpers for connecting to access points
- `main/app.zig` – Application entry point (`app_main`) that orchestrates subsystem initialization
- [`docs/getting-started.md`](https://github.com/kassane/zig-esp-idf-sample/blob/main/docs/getting-started.md) – Build system configuration and toolchain setup instructions

## Summary

- The **zig-esp-idf-sample** repository demonstrates production-ready **Zig HTTP client and server examples for ESP-IDF** using thin wrappers over ESP-IDF C APIs.
- The **HTTP server** implementation in `imports/http.zig` provides `idf.http.Server.start()`, `registerUri()`, and `Response.sendStr()` for handling GET requests.
- The **HTTP client** exposes `idf.http.Client` with `init()`, `perform()`, and `read()` methods for synchronous HTTP requests.
- All **ESP-IDF error codes** are automatically converted to Zig error unions via `idf.err.espCheckError`, enabling idiomatic `try` and `catch` error handling.
- The **server configuration** uses `sys.httpd_config_t` to define stack size (4096 bytes), task priority (5), and maximum URI handlers (8).
- Both components can be combined to implement **proxy patterns** or API gateways on ESP32 hardware.

## Frequently Asked Questions

### How do I change the HTTP server port in the ESP-IDF Zig implementation?

Modify the `server_port` field in the `sys.httpd_config_t` struct before calling `idf.http.Server.start()`. The default configuration sets `config.server_port = 80`, but you can specify any valid port number (e.g., 8080) for development or deployment scenarios requiring non-privileged ports.

### Can the Zig HTTP client handle POST requests with request bodies?

Yes. After initializing the client with `idf.http.Client.init()`, use `sys.esp_http_client_set_method()` to set the method to `HTTP_METHOD_POST`, then write the request body using `sys.esp_http_client_write()` before calling `client.perform()`. The wrapper in `imports/http.zig` exposes these underlying ESP-IDF functions for full HTTP method support.

### How does error handling work between Zig and ESP-IDF in these examples?

The `idf.err.espCheckError` function (used throughout `imports/http.zig`) translates ESP-IDF C error codes (like `ESP_FAIL` or `ESP_ERR_NO_MEM`) into Zig error unions. This allows Zig code to use `try` for propagation and `catch` for specific error handling, while C handlers in server callbacks return `sys.esp_err_t` values directly to the ESP-IDF HTTP daemon.

### What is the minimum stack size required for the HTTP server task?

According to the implementation in `main/examples/http-server.zig`, the server task requires at least **4096 bytes** of stack (`config.stack_size = 4096`). This accommodates the HTTP daemon's internal buffers and the Zig handler function call frames. Increase this value if your URI handlers perform heavy allocations or complex processing.