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

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:

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:

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():

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:

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:

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:

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:

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 – 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →