A Zephyr Web Server on the Seeed XIAO nRF52840, over Thread (C/C++)
Mesh Networks -- Photo by Conny Schneider / Unsplash

A Zephyr Web Server on the Seeed XIAO nRF52840, over Thread (C/C++)

In an earlier guide we built a tiny web server on an ESP32-C3 in async Rust, using Embassy. A browser could connect over Wi-Fi, toggle an onboard LED, and flip the page theme. This guide builds the same idea on completely different foundations: the Zephyr RTOS, written in C, running on a Seeed Studio XIAO nRF52840. A later companion guide will return to this board and do it in Rust, so we can compare all three points of the triangle.

This is the more ambitious of the two projects, because the XIAO nRF52840 has no Wi-Fi. We will reach it over Thread, the low-power IPv6 mesh networking standard built on 802.15.4. That choice makes this exercise much more challenging and quite different to a straight port.

⚠️
This guide is a snapshot, current as of mid-2026 Zephyr moves quickly and its build system, Kconfig symbols, and west commands change between releases. This article was written against Zephyr 4.x and its bundled OpenThread, with the Zephyr SDK installed through west sdk install. If a config option or command below does not match what you see, check your Zephyr version first: the structure of the project carries over even when the exact symbol names change. The board target used throughout is xiao_ble, which is the Seeed XIAO nRF52840 in mainline Zephyr. The Sense variant uses xiao_ble/nrf52840/sense.

What is Zephyr?

The ESP32 project in my previous post used Embassy: a small async runtime that you combine with a bare-metal program. Zephyr is a different kind of thing entirely. It is a full real-time operating system (RTOS): a pre-emptive scheduler, a driver model, a networking stack, filesystems, power management, and a build system, all maintained as one large open-source project under the Linux Foundation.

A few ideas define how Zephyr works, and they are worth discussing up front because the rest of this guide leans on them:

  • Pre-emptive threads, not just async. Zephyr runs real kernel threads with priorities. The scheduler can interrupt a lower-priority thread to run a higher-priority one. You still can write cooperative code, but the default model is closer to a small Linux than to a single-stack async runtime.
  • Devicetree. Hardware is described in a .dts data format, separate from your C code. The LED, the radio, and the pins are all nodes in a tree. Your program refers to them by alias (led0) rather than by hardcoding a pin number, which is how one application builds unchanged across hundreds of boards. The Devicetree is discussed in detail in my BeagleBone and Raspberry Pi textbooks, being present in embedded Linux for quite some time now.
  • Kconfig. Features are switched on and off through CONFIG_ symbols in a prj.conf file, exactly like configuring a Linux kernel. If you want TCP, or OpenThread, or a shell, you enable a symbol rather than adding a dependency.
  • west. Zephyr's meta-tool. It manages the source tree, builds, and flashes. You will type west build and west flash (or copy) constantly.

The value of all this machinery is its breadth, as Zephyr supports a huge range of chips and ships a maintained networking stack that already has IPv6, TCP, and Thread. The cost is a steeper learning curve, which the rest of this guide tries to assist with.

The hardware: Seeed Studio XIAO nRF52840

The XIAO nRF52840 is a thumbnail-sized board (roughly 22 mm by 17.5 mm) built around the Nordic nRF52840 system-on-chip. It is inexpensive (~ €10), widely available, and very well supported in Zephyr, which makes it a good board to learn on.

Figure 1. The Seeed Studio XIAO-nRF52840
Feature Detail
MCU Nordic nRF52840, Arm Cortex-M4F at 64 MHz
Memory 1 MB flash, 256 KB RAM
Radios Bluetooth LE 5, 802.15.4 (Thread / Zigbee), NFC
Wireless gap No Wi-Fi and no Ethernet
USB Native USB device controller
User LED RGB LED, active-low
Bootloader Adafruit UF2 (double-tap reset button in Figure 1 to flash)

Two of these rows shape the whole project. First, the lack of Wi-Fi: the nRF52840 is a Bluetooth and 802.15.4 chip, so to build a browser-reachable web server we use Thread (more on that below). Second, the UF2 bootloader: you flash the board by double-tapping the reset button, at which point it appears as a USB mass-storage drive, and you copy a .uf2 file onto it. This is a nice feature, which means you do not need an external debug probe to get started, keeping the barrier to entry low.

💡
The user LED is active-low Like the ESP32-C3 SuperMini in the previous guide, the XIAO drives its LED active-low: pulling the pin low turns the LED on. Zephyr hides this for you. The board's devicetree marks the LED GPIO_ACTIVE_LOW, so in code a logical 1 means on and a logical 0 means off, and you never think about the electrical level again. This is a simple example of devicetree doing its magic!

What we are building

The goal is feature-for-feature the same as the ESP32 guide: a single-page web server that serves a small HTML page with two buttons. One button toggles the user LED on the board; the other flips the page between a dark and a light theme. The page is rendered fresh on every request to reflect the current state.

The difference is entirely in how the bytes get from your computer to the embedded board. On the ESP32 the path was browser to Wi-Fi access point to the chip. Here the path runs through a Thread mesh:

 
  graph LR 
     B["Browser on your PC"] -->|"IPv6 over Wi-Fi / Ethernet"| R["Thread Border Router"] 
     R -->|"802.15.4 / 6LoWPAN"| X["XIAO nRF52840: HTTP server + LED"] 
Figure 2: a browser on your PC talks IPv6 to a Thread Border Router over your normal network, and the border router relays it over 802.15.4 to the XIAO, which runs the HTTP server and owns the LED.

The XIAO never touches your Wi-Fi directly. It joins a Thread network, receives an IPv6 address, and the border router routes traffic between your home network and the mesh. From the browser's point of view it is just another IPv6 host.

Zephyr versus Embassy: choosing your foundation

Before the setup work, it is worth being clear about why you might pick one of these over the other, because they use quite different philosophies. Neither is better; they trade against each other.

Dimension Zephyr (C/C++) Embassy (async Rust)
Concurrency model Pre-emptive RTOS threads, plus optional cooperative work queues Cooperative async/await on a single stack
Memory safety Manual; the usual C foot-guns apply Enforced at compile time by the borrow checker
Footprint Larger; you are linking an RTOS and a full network stack Very small for simple apps; no OS underneath
Hardware breadth Huge: hundreds of boards behind one devicetree abstraction Growing, but per-chip HALs vary and move fast
Networking Mature in-tree stack: IPv6, TCP, Thread, BLE, USB Depends on external crates; capable but younger
Build system west + CMake + Kconfig + devicetree (powerful, complex) cargo (simple, familiar to Rust developers)
Maturity Long-term support, safety certification paths, large industry backing Younger ecosystem, smaller community, rapid change

Zephyr Strengths. If your project needs breadth, longevity, or a feature that already exists in-tree, Zephyr is hard to beat. The Thread stack we use here is a good example: it is maintained, tested, and a single Kconfig symbol away. Zephyr's board abstraction also means the same application source can target a wildly different chip by changing one build flag. For a product that must ship, be certified, and live for a decade, that maturity matters.

Embassy Strengths. If your project is a focused, resource-constrained device and you value Rust's compile-time safety, Embassy is lighter and arguably more pleasant. There is no RTOS to learn, the async model is elegant for I/O-bound work, and the borrow checker eliminates a whole category of concurrency bugs before the program ever runs. The cost, as the ESP32 guide's repeated version caveats showed, is a faster-moving ecosystem with thinner driver coverage.

A useful way to hold the distinction: Embassy gives you concurrency by suspending tasks at .await points on one stack, while Zephyr gives you concurrency by scheduling real threads that the kernel can preempt. The ESP32 web server was one cooperative task looping forever. The Zephyr version will be a thread that the kernel schedules alongside the entire Thread networking stack running in its own threads underneath.

Prerequisite: a Thread border router

You need a border router for this project Thread is an IPv6 mesh, but it is not directly reachable from your laptop's browser on its own. A Thread Border Router (a device that sits on both your normal network and the Thread mesh and routes between them) is required. Without one, the XIAO can join a Thread network but nothing on your Wi-Fi can reach it.

The common do-it-yourself option is the OpenThread Border Router (OTBR) running on a Raspberry Pi, paired with a second nRF52840 (a dongle or another XIAO) flashed with the Radio Co-Processor (RCP) firmware to act as the Pi's 802.15.4 radio. Commercial Thread border routers exist (many smart-home hubs are one), but most do not expose the developer access we need here.

Setting up the border router is a guide in its own right, which I have written on my site using an ESP32-C6-WROOM as an OpenThread RCP. The OpenThread documentation covers it well, so this article assumes you have a working OTBR and can read its active dataset. We will use that dataset to commission the XIAO onto the same network.

First-time Zephyr toolchain setup on Windows

Zephyr's toolchain has more moving parts than cargo, but the official path is well-covered. These steps follow Zephyr's getting-started flow for Windows; pin them against the current documentation for your version.

🍫
What is Chocolatey? Chocolatey is a command-line package manager for Windows, in the same spirit as apt on Debian or brew on macOS. Instead of hunting down installers for CMake, Ninja, Python, and the rest, you run one choco install command and it fetches, installs, and puts each tool on your PATH for you. Zephyr's own Windows instructions rely on it precisely because the toolchain has so many separate pieces, and keeping them consistent by hand is error-prone.

If you do not already have it, install it once from an elevated PowerShell window by following the official install command. You can check it is present with choco --version. It is not the only option (Windows also ships winget, and Scoop is popular too), but Chocolatey is what the Zephyr docs assume, so it is the smoothest path here.

1. Install the host dependencies

Open a PowerShell window (with elevated permissions – i.e., right-click PowerShell and "Run as Administrator") and install the build tools with Chocolatey:

choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
choco install ninja gperf python git dtc-msys2 wget 7zip
💡
Tip: Close and reopen your terminal after installing, so the updated PATH is picked up. Zephyr is sensitive to having cmake, ninja, and python all visible on the path.

2. Get the Zephyr source and Python tools

Zephyr's meta-tool, west, is a Python package. Install it into a virtual environment so it does not collide with other Python projects:

⚠️
Warning! Do not install zephyr in a directory with a space – e.g. c:\users\Derek Molloy\ – it will not work correctly – ask me how I know!
PS C:\> mkdir c:\zephyr
PS C:\> cd c:\zephyr
PS C:\zephyr> python -m venv zephyrproject\.venv
PS C:\zephyr> zephyrproject\.venv\Scripts\Activate.ps1
(.venv) PS C:\zephyr> pip install west

While you are here in Windows as administrator, it is a good time to fix a quirk with Windows path length limits. When the Microsoft C compiler (MSVC) tries to generate intermediate build files inside this deeply nested folder later in this section, the file path exceeds the legacy Windows 260-character limit, causing the compiler to choke and output that weird '' empty file name error. We can address this now.

💡
This same long path length issue arises when building project with std Rust.
PS C:\WINDOWS\system32> New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force

LongPathsEnabled : 1
PSPath           : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem
PSParentPath     : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control
PSChildName      : FileSystem
PSDrive          : HKLM
PSProvider       : Microsoft.PowerShell.Core\Registry
💡
PowerShell, not cmd: these are PowerShell commands, so use PowerShell's own syntax for the home directory ($HOME, or $env:HOMEPATH, or ~) rather than the cmd-style %HOMEPATH%, which PowerShell does not expand. Likewise the venv is activated with Activate.ps1 in PowerShell; the activate.bat form is for a cmd prompt. If you would rather use cmd throughout, %HOMEPATH% and activate.bat are the correct choices there instead.

With west installed, pull down the Zephyr tree and its dependencies:

(.venv) PS C:\zephyr> west init zephyrproject
=== Initializing in C:\zephyr\zephyrproject
--- Cloning manifest repository from https://github.com/zephyrproject-rtos/zephyr
Cloning into 'C:\zephyr\zephyrproject\.west\manifest-tmp'...
remote: Enumerating objects: 1558369, done.
remote: Counting objects: 100% (1369/1369), done.
...
--- setting manifest.path to zephyr
=== Initialized. Now run "west update" inside C:\zephyr\zephyrproject.

(.venv) PS C:\zephyr> cd zephyrproject
(.venv) PS C:\zephyr\zephyrproject> west update
(.venv) PS C:\zephyr\zephyrproject> pip install -r zephyr\scripts\requirements.txt
(.venv) PS C:\zephyr\zephyrproject> west zephyr-export

Zephyr (C:/zephyr/zephyrproject/zephyr/share/zephyr-package/cmake)
has been added to the user package registry in:
HKEY_CURRENT_USER\Software\Kitware\CMake\Packages\Zephyr
...

west update clones Zephyr's many module repositories (including OpenThread), so it takes a while and downloads a few hundred megabytes the first time.

3. Install the Zephyr SDK

The SDK provides the cross-compilers (here, the Arm toolchain that targets the Cortex-M4 in the nRF52840):

(.venv) PS C:\zephyr> west sdk install --install-dir C:\zephyr\zephyr-sdk

That installs the full SDK. If you want to keep the download smaller you can pass -t arm-zephyr-eabi to fetch only the Arm toolchain, which is all this board needs.

🚧
Note: From here on, every new terminal session that builds Zephyr must first activate the virtual environment (zephyrproject\.venv\Scripts\Activate.ps1 in PowerShell, or activate.bat in cmd) and change into the zephyrproject directory. A common first-time frustration is west not being found, which almost always means the venv is not active.

Creating the Zephyr project

A Zephyr application is a directory with three things: a CMakeLists.txt, a prj.conf, and your source. Create it as a sibling of zephyr inside your workspace, for example zephyrproject\een-zephyr-web.

(.venv) PS C:\zephyr\zephyrproject> mkdir een-zephyr-web
(.venv) PS C:\zephyr\zephyrproject> cd .\een-zephyr-web\

Now create the three following files in this directory:

Build Configuration (CMakeLists.txt)

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(een_zephyr_web)

target_sources(app PRIVATE src/main.c)

Project Configuration (prj.conf)

This file is where most of the work happens. Each symbol switches on a slice of Zephyr. The comments explain why each is needed for our web server example:

# --- Networking core ---
CONFIG_NETWORKING=y
CONFIG_NET_IPV6=y
CONFIG_NET_TCP=y
CONFIG_NET_SOCKETS=y
CONFIG_POSIX_API=y          # gives us the familiar BSD socket() / bind() names

# --- Non-Volatile Storage (Required for OpenThread) --- 
CONFIG_FLASH=y 
CONFIG_FLASH_MAP=y 
CONFIG_NVS=y 
CONFIG_SETTINGS=y 
CONFIG_SETTINGS_NVS=y

# --- 802.15.4 + Thread ---
CONFIG_NET_L2_OPENTHREAD=y  # run the OpenThread stack as the network link layer
CONFIG_OPENTHREAD_FTD=y     # Full Thread Device: routes for others, stays awake

# --- Shell, so we can commission onto the Thread network at runtime ---
CONFIG_SHELL=y
CONFIG_OPENTHREAD_SHELL=y
CONFIG_NET_SHELL=y

# --- Hardware we touch ---
CONFIG_GPIO=y               # for the LED

# --- Diagnostics ---
CONFIG_LOG=y

# --- Headroom: the Thread stack and TCP need RAM ---
CONFIG_MAIN_STACK_SIZE=4096
CONFIG_HEAP_MEM_POOL_SIZE=16384

# --- Shell paste headroom: the Thread dataset is a long single line ---
CONFIG_SHELL_CMD_BUFF_SIZE=512
CONFIG_SHELL_BACKEND_SERIAL_RX_RING_BUFFER_SIZE=512

# --- Give the device a routable (OMR) address via SLAAC ---
CONFIG_OPENTHREAD_SLAAC=y
💡
The numbers in the Headroom block are a starting point, not fixed. If the device resets or reports stack overflows once Thread is running, raise CONFIG_MAIN_STACK_SIZE. Tuning these is a normal part of Zephyr work.

The web server (src/main.c)

The third file is in a new src sub directory. Here is src/main.c. It mirrors the structure of the ESP32 program deliberately: configure the LED, open a listening socket, then loop forever accepting one client at a time, reading the request, updating state, rendering a page, and closing. The comments carry the detail.

⚠️
Do not paste this file through a terminal editor. The long HTML string literals are exactly the lines that get mangled if you paste into nano, PuTTY, or anything that wraps or truncates long lines: a string literal that gets split across two physical lines is a compile error (missing terminating " character). Save it with a real editor (VS Code) or write it to disk directly, and confirm the file is intact afterwards. Check that render_page appears exactly once and the file should end with main()'s closing brace.
// src/main.c
// A minimal HTTP/1.0 server on Zephyr for the XIAO nRF52840.
// Serves one client at a time over the Thread (IPv6) network: toggles the
// user LED and flips a page theme, exactly like the ESP32-C3 example.
//
// LEARNING NOTES
// --------------
// Zephyr is a small real-time operating system (RTOS): instead of the single
// super-loop of bare-metal Arduino code, your program runs as one or more
// "threads" scheduled by a kernel, alongside the kernel's own threads (e.g. the
// networking stack). This file is one such application thread: the main().
//
// Thread (capital T, the networking protocol) is a low-power IPv6 mesh network
// built on 802.15.4 radios. Every node gets real IPv6 addresses and speaks
// standard IP, so from this program's point of view the network is "just
// sockets" -- the same BSD-style API you would use on Linux. The mesh, routing,
// and radio work all happen in Zephyr's networking threads underneath us.
//
// Two Zephyr ideas appear again and again below:
//   * Devicetree -- hardware (the LED pin, the radio) is described in .dts/.overlay
//     files, and the code refers to it by name rather than by raw register/pin
//     numbers. See the GPIO_DT_SPEC_GET line.
//   * Kconfig     -- features (networking, Thread/OpenThread, logging, socket
//     API) are switched on in prj.conf. If a CONFIG_* option is off, the matching
//     code will not link. This file assumes the net + OpenThread + socket options
//     are enabled.

#include <zephyr/kernel.h>         // core RTOS: threads, printk(), the main() entry
#include <zephyr/logging/log.h>    // structured logging subsystem (LOG_INF/WRN/ERR)
#include <zephyr/drivers/gpio.h>   // GPIO driver API + devicetree GPIO helpers
#include <zephyr/net/socket.h>     // Zephyr's BSD-style socket API (zsock_* calls)
#include <string.h>
#include <stdio.h>

// Register a logging module named "web". Zephyr routes LOG_INF/WRN/ERR calls
// through a background logging thread so that logging never blocks your code,
// and the level (here INF) can be raised or lowered per-module in Kconfig.
LOG_MODULE_REGISTER(web, LOG_LEVEL_INF);

#define HTTP_PORT 80

// Grab the user LED from the board devicetree by its alias. This is the core
// Zephyr devicetree pattern: led0 is an *alias* defined in the board's .dts,
// pointing at a real GPIO node; GPIO_DT_SPEC_GET resolves it at compile time
// into a struct holding the port, pin number, and flags. The devicetree flags
// include GPIO_ACTIVE_LOW, so a logical 1 means ON and we never have to think
// about the electrical level -- change boards and only the devicetree changes,
// not this code.
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);

// Server-side state, shared across every client (one LED, one theme), just as
// in the ESP32 version. Because we handle exactly one client at a time in the
// main thread (see the accept loop), these need no locking; if you fanned out
// to multiple worker threads you would have to protect them with a mutex.
static bool led_on;
static bool dark = true;

// Render the page reflecting current state into buf. Returns the body length.
static int render_page(char *buf, size_t len)
{
    const char *state        = led_on ? "ON" : "OFF";
    const char *action       = led_on ? "Turn OFF" : "Turn ON";
    const char *theme        = dark ? "dark" : "light";
    const char *theme_action = dark ? "Light mode" : "Dark mode";

    return snprintf(buf, len,
        "<!DOCTYPE html><html data-theme=\"%s\"><head>"
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
        "<title>XIAO nRF52840</title>"
        "<link rel=\"stylesheet\" href=\"https://derekmolloy.ie/assets/built/theme.css\">"
        "</head><body class=\"dm\">"
        "<h1>Hello from Zephyr on the XIAO nRF52840</h1>"
        "<p>User LED is currently <b>%s</b>.</p>"
        "<form action=\"/toggle\" method=\"get\">"
        "<button type=\"submit\" style=\"font-size:1.5rem;padding:0.6rem 1.4rem\">%s</button>"
        "</form>"
        "<form action=\"/theme\" method=\"get\">"
        "<button type=\"submit\" style=\"font-size:1.5rem;padding:0.6rem 1.4rem;margin-top:0.6rem\">%s</button>"
        "</form>"
        "<p>Served by Zephyr over Thread. See ["
        "<a href=\"https://derekmolloy.ie/\">derekmolloy.ie</a>]</p>"
        "</body></html>",
        theme, state, action, theme_action);
}

// Send the whole buffer, looping because a single send() may be partial.
// This is standard TCP socket discipline, not Zephyr-specific: zsock_send()
// (Zephyr's send()) can accept fewer bytes than asked, so we resend from where
// it left off until everything is out. zsock_* is simply Zephyr's namespaced
// version of the POSIX socket calls.
static int send_all(int sock, const char *data, size_t len)
{
    size_t sent = 0;
    while (sent < len) {
        int n = zsock_send(sock, data + sent, len - sent, 0);
        if (n < 0) {
            return -1;
        }
        sent += n;
    }
    return 0;
}

// Handle a single connected client: read the request, route, respond, close.
static void handle_client(int client)
{
    // static keeps these buffers out of the thread's stack. Embedded threads
    // in Zephyr have small, fixed stacks (set via CONFIG_MAIN_STACK_SIZE), and
    // 2 KB+ of local arrays could overflow it. Static storage is safe here only
    // because a single thread ever runs this function at a time.
    static char req[1024];
    static char body[1024];
    static char header[128];

    // Read until the blank line that ends the HTTP headers. A GET has no body.
    // zsock_recv() blocks this thread until bytes arrive; meanwhile the kernel
    // keeps running the Thread networking threads that are feeding us those bytes.
    int len = 0;
    while (len < (int)sizeof(req) - 1) {
        int n = zsock_recv(client, req + len, sizeof(req) - 1 - len, 0);
        if (n <= 0) {
            break;
        }
        len += n;
        req[len] = '\0';
        if (strstr(req, "\r\n\r\n")) {
            break;
        }
    }

    // Minimal HTTP parsing -- we do NOT use a full HTTP library here so you can
    // see exactly what a request is: a plain-text line. The request line looks
    // like "GET /toggle HTTP/1.1". Pull out the path: skip the method, then cut
    // the path at the first space or '?'.
    const char *path = "/";
    char *first_space = strchr(req, ' ');
    if (first_space) {
        path = first_space + 1;
        char *end = strpbrk((char *)path, " ?");
        if (end) {
            *end = '\0';
        }
    }

    // Routing: one action per path, mirroring the ESP32 match arms.
    if (strcmp(path, "/toggle") == 0) {
        led_on = !led_on;
    } else if (strcmp(path, "/on") == 0) {
        led_on = true;
    } else if (strcmp(path, "/off") == 0) {
        led_on = false;
    } else if (strcmp(path, "/theme") == 0) {
        dark = !dark;
    }
    // Drive the physical pin to match our logical state. Because the devicetree
    // marked this pin GPIO_ACTIVE_LOW, passing 1 here means "logically on" and
    // Zephyr inverts it to the correct electrical level for us.
    gpio_pin_set_dt(&led, led_on ? 1 : 0);

    // Render the page, build a tiny HTTP/1.0 response, send header then body.
    int body_len = render_page(body, sizeof(body));
    int header_len = snprintf(header, sizeof(header),
        "HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n"
        "Content-Length: %d\r\nConnection: close\r\n\r\n", body_len);

    if (send_all(client, header, header_len) == 0) {
        send_all(client, body, body_len);
    }
    zsock_close(client);
}

// main() runs as its own Zephyr thread, started automatically by the kernel
// after boot. We do NOT bring up the Thread network here: with the OpenThread
// options enabled in Kconfig, Zephyr joins the mesh and configures IPv6
// addresses on its own in the background before main() even runs. So by the
// time we reach the socket calls below, the node already has a working IPv6
// stack -- we just use it.
int main(void)
{
    // printk() is the low-level, always-available console print (distinct from
    // the LOG_* macros, which go through the logging subsystem). Handy for the
    // very first "did we boot?" messages before anything else is set up.
    printk("=== web server main() starting ===\n");

    // Bring up the LED. gpio_is_ready_dt guards against a missing devicetree
    // node (the driver may not have initialised, or the alias may be absent);
    // GPIO_OUTPUT_INACTIVE configures the pin as an output in the logical-off
    // state. Always check *_is_ready_dt before touching a device in Zephyr.
    if (!gpio_is_ready_dt(&led)) {
        LOG_ERR("LED device not ready");
        return 0;
    }
    gpio_pin_configure_dt(&led, GPIO_OUTPUT_INACTIVE);

    // Open an IPv6 TCP socket and listen on port 80. AF_INET6 because Thread is
    // an IPv6-only network. Binding to in6addr_any (the "::" address) accepts
    // connections on whichever Thread-assigned IPv6 address the node ends up
    // with, so we don't have to hard-code it.
    int serv = zsock_socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
    if (serv < 0) {
        LOG_ERR("Failed to create socket");
        return 0;
    }

    // Fill in the address to bind to. htons() converts the port to network
    // byte order (big-endian) -- required even on little-endian MCUs like the
    // nRF52840, and a classic beginner gotcha if you forget it.
    struct sockaddr_in6 addr = {
        .sin6_family = AF_INET6,
        .sin6_addr = IN6ADDR_ANY_INIT,
        .sin6_port = htons(HTTP_PORT),
    };

    if (zsock_bind(serv, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        LOG_ERR("bind() failed");
        return 0;
    }
    // Mark the socket passive so it can accept connections. The backlog of 2 is
    // the queue of pending connections; small is fine here since we serve one
    // client at a time.
    if (zsock_listen(serv, 2) < 0) {
        LOG_ERR("listen() failed");
        return 0;
    }

    printk("=== HTTP server listening on port 80 ===\n");
    LOG_INF("HTTP server listening on [::]:%d", HTTP_PORT);

    // Accept one client at a time, forever. zsock_accept() blocks this thread
    // until a browser connects; while we are parked here the kernel keeps the
    // Thread mesh, radio, and IP stack alive in their own threads, so the node
    // stays on the network even when our code is "doing nothing". This blocking,
    // one-thread-per-loop style is easy to read but only serves one client at a
    // time -- a real server would hand each client off to a worker thread.
    while (1) {
        int client = zsock_accept(serv, NULL, NULL);
        if (client < 0) {
            LOG_WRN("accept() failed");
            continue;
        }
        handle_client(client);
    }

    return 0;
}

💡
Why zsock_ prefixes? With CONFIG_POSIX_API=y you can write plain socket(), bind(), and so on. The zsock_-prefixed names are the underlying Zephyr calls, used here to be explicit and to avoid any clash with other POSIX headers. Either style works; pick one and stay consistent.
HTTP or CoAP? A browser speaks HTTP over TCP, so that is what we serve. On a constrained Thread mesh the more idiomatic protocol is CoAP (compact, runs over UDP), and Zephyr supports it well. CoAP is the better choice for machine-to-machine traffic, but it is not something you can point a browser at, so it would break the parity with the ESP32 guide. We stay with HTTP for that reason.

At this point, my project looks like:

PS C:\zephyr\zephyrproject> tree /F .\een-zephyr-web\
Folder PATH listing
Volume serial number is 00000167 D2E8:0917
C:\ZEPHYR\ZEPHYRPROJECT\EEN-ZEPHYR-WEB
│   CMakeLists.txt
│   prj.conf
│
└───src
        main.c

Building and flashing

From your activated environment, inside zephyrproject, build for the XIAO and produce the UF2 image:

(.venv) PS C:\zephyr\zephyrproject> west build -b xiao_ble een-zephyr-web
-- west build: generating a build system
Loading Zephyr default modules (Zephyr base).
-- Application: C:/zephyr/zephyrproject/een-zephyr-web
-- CMake version: 4.3.4
...
Generating files from C:/zephyr/zephyrproject/build/zephyr/zephyr.elf for board: xiao_ble/nrf52840
Converted to uf2, output size: 785408, start address: 0x27000
Wrote 785408 bytes to zephyr.uf2

To flash, put the board into its bootloader by double-tapping the reset button. It mounts as a USB drive (named something like XIAO-SENSE, or XIAO-BOOT on the non-Sense board). Copy the built image onto it:

(.venv) PS C:\zephyr\zephyrproject> copy .\build\zephyr\zephyr.uf2 I:\

Replace I:\ with whatever drive letter the board's bootloader drive is given. You will see the file begin to appear on the drive, and then the board reboots automatically into the new firmware once the copy completes.

🔍
Where is the reset button? It is tiny! On the XIAO nRF52840 (and the Sense variant) the reset button is a minuscule surface-mount button on the top face of the board, right beside the USB-C connector on the same short edge (See Figure 1). It is far smaller than you expect: more of a little metal nub than an obvious push button, so if you are of a certain age and your eyesight is at a certain level, it is very easy to conclude the board has no button at all – it is there!

Double-tap it with your finger nail like a mouse double-click: two quick presses. Getting the timing right is difficult, so if nothing happens, try again a little faster or a little slower. You know it worked when the small onboard LED starts slowly pulsing (a "breathing" effect) and the XIAO-SENSE drive appears with a Windows alert. If the button is impossible to press cleanly, the electrical equivalent is to short the RST pad to GND twice in quick succession (check your board's pinout for the pad locations first).
💡
Tip: If west build complains it cannot find a board called xiao_ble, your Zephyr tree predates its support or your environment is not pointed at the right Zephyr. Confirm with west boards | findstr xiao.

Joining the Thread network

The firmware is running, but the XIAO is not yet on your Thread network. To commission it you need to reach the Zephyr shell we enabled in prj.conf, which is exposed over the board's USB CDC-ACM serial port. So the first job is to open a serial terminal to the board.

💡
CDC-ACM stands for Communications Device Class – Abstract Control Model. It's a part of the USB specification that lets a USB device present itself to the host as a plain serial port (a virtual COM port), even though there's no real UART or physical serial cable involved.

Opening a serial terminal to the board

Because the XIAO presents a native USB serial device, the same USB-C cable you flashed with also carries the shell: there is no separate UART adapter to wire up. When the firmware boots, the board enumerates as a virtual COM port on Windows (something like COM7). You can find the exact number in Device Manager → Ports (COM & LPT), where it appears as a USB Serial Device; unplugging and re-plugging the board shows you which entry it is.

Any serial terminal program will do. On Windows the usual choices are PuTTY or Tera Term, and the Arduino IDE or VS Code serial monitors work equally well. Point your terminal at that COM port with these settings:

Setting Value
Speed (baud) 115200
Data bits 8
Parity None
Stop bits 1
Flow control None

This is the familiar 115200 8N1, no flow control, which is the Zephyr shell default. As an example, the equivalent PuTTY launch from the command line is:

putty -serial COM7 -sercfg 115200,8,n,1,N
💡
Baud rate on a USB serial port: because this is USB CDC-ACM rather than a real UART, the baud rate is essentially cosmetic: the data does not actually travel at 115,200 bit/s, so a mismatched speed will not garble the text the way it would on a physical serial line. Setting 115200 keeps you consistent with the rest of the Zephyr documentation, but almost any value connects. What matters far more is picking the correct COM port.

Once connected, press Enter and you should see the shell prompt:

uart:~$

If you see nothing, press Enter again or tap the reset button once to reboot the firmware (a normal single tap, not the double-tap that enters the bootloader) and watch the boot log scroll past. The shell supports Tab completion and command history, so typing ot then Tab lists the OpenThread subcommands, which is handy for exploring beyond the few used below.

A single tap of the reset button reboots the firmware and prints a short banner. On a healthy board it looks like this:

*** Booting Zephyr OS build v4.4.0-7043-g777ab585520e ***
[00:00:00.457,519] <inf> udc_nrf: Initialized
WARNING: Using a potentially insecure PSA ITS encryption key provider.
[00:00:00.457,824] <wrn> secure_storage: Using a potentially insecure PSA ITS encryption key provider.
=== web server main() starting ===
=== HTTP server listening on port 80 ===
[00:00:00.458,190] <inf> web: HTTP server listening on [::]:80
[00:00:00.461,791] <inf> udc_nrf: SUSPEND state detected
[00:00:00.563,110] <inf> udc_nrf: Reset
[00:00:00.563,171] <inf> udc_nrf: RESUMING from suspend
[00:00:00.611,785] <inf> udc_nrf: Reset

It is worth knowing what each part is:

  • *** Booting Zephyr OS build … *** is the kernel banner, printed by printk before anything else. The v4.4.0-… string is the exact Zephyr revision you built against, which is handy to quote if you ever compare notes or file a bug.
  • [00:00:00.457,519] <inf> udc_nrf: … lines are timestamped log messages (seconds.milliseconds,microseconds since boot). udc_nrf is the USB device controller bringing up the CDC-ACM serial port you are reading this on; the SUSPEND / Reset / RESUMING chatter is just the USB link enumerating with the host and is entirely normal.
  • The secure_storage warning about "a potentially insecure PSA ITS encryption key provider" is expected on this board. It means the settings store (where the Thread dataset is kept) is not protected by a hardware-backed key. For a hobby project on your own network that is fine; it is Zephyr reminding you not to ship a product this way.
  • === web server main() starting === and === HTTP server listening on port 80 === are our own printk markers from main.c. Seeing both is the proof that the application actually ran and the listening socket was created — the single most useful line to look for when the browser cannot connect (see the troubleshooting later). The line immediately below, <inf> web: HTTP server listening on [::]:80, is the same milestone emitted through the logging system by our LOG_MODULE_REGISTER(web, …) module.
  • The log messages and the plain printk markers can interleave in either order, because printk writes immediately while the log subsystem processes its queue slightly later. That is why the two === … === lines can appear just above the matching <inf> web: line rather than exactly where you might expect.

Commissioning onto the network

With the shell open, commission the board using the dataset from your border router.

On the border router, read its active operational dataset as hex. If your border router is the OpenThread Border Router from the companion guide, read it from the container:

docker exec -it otbr ot-ctl dataset active -x

On a stand-alone OpenThread node the command is the same, without the ot-ctl wrapper:

> dataset active -x
000300000c0102f095...   (one long hex string)
💡
This dataset is the link between the two guides. The hex string is the network's Active Operational Dataset: its name, PAN ID, channel, and keys. The XIAO must be given the exact same dataset as the border router you want it to appear behind; otherwise it forms its own separate mesh that your browser cannot reach. Whatever network your border router is on (in my case a merged Google Nest network) is the dataset you copy here.

Then, in the XIAO's Zephyr shell, apply that dataset, bring the interface up, and start Thread:

uart:~$ ot dataset set active 000300000c0102f095...
Done
uart:~$ ot ifconfig up
Done
uart:~$ ot thread start
Done

Do the first step very carefully. I found it best to break the hex string into four parts and paste them one after the other.

⚠️
Warning: Pasting that long hex string is the challenging part. Two things go wrong here:The shell drops characters on a fast paste. If you see shell_uart: RX ring buffer full warnings, the paste outran the receive buffer and the dataset is now corrupt. The CONFIG_SHELL_* buffer sizes we added to prj.conf largely fix this; if it still happens, use Tera Term with a small transmit delay (Setup → Serial port → about 1 msec/char), or paste the hex in shorter chunks.Type the command, paste only the hex. Type ot dataset set active by hand and paste just the hex onto the end. That keeps the fragile bytes in one place and stops a dropped character from mangling the command word itself. A mistyped word gives Error 35: InvalidCommand (the parser rejected the command, for example datset instead of dataset), which is a different failure from Error 7: InvalidArgs (the hex itself was bad).

Always verify the dataset landed intact before trusting it. Print it back and check it matches the border router's, character for character (the full value is here for reference):

uart:~$ ot dataset active -x
000300000c0102f095020879662de063397cca0e080000630df03ba0d50510670d7590b4fb620c934464c639e1669b030d4e4553542d50414e2d463039350708fda5600f64860000041015d9ae97a0c61983e80b805e9a7458080c0402a0f77835060004001fffe0
Done

After a few seconds, check that the device has joined and list its addresses:

uart:~$ ot state
router
Done
uart:~$ ot ipaddr
fd5c:2f13:2208:1:1e70:3842:fd96:4248
fda5:600f:6486:0:0:ff:fe00:a800
fda5:600f:6486:0:ab3b:f7a5:f9e1:ca15
fe80:0:0:0:e05b:494c:6b17:b7f9
Done

ot state should settle on child or router within a few seconds. If it stays detached, the dataset is wrong or the radio cannot hear the border router

💡
ot parent, and what your role tells you ot parent prints the router your node is attached to as a child, including the link-quality figures for that uplink, which makes it the natural way to judge signal strength. But it only works when the node actually has a parent: run it on a router or leader and you get Error 13: InvalidState, because those roles sit at or near the top of the mesh and have no parent. So the command failing is itself a clue about your role.

Watch ot state closely. child is healthy and expected for a leaf device, and router is fine too. But if a device that should be a leaf becomes leader (and you see the leader anycast address …:0:0:ff:fe00:fc00 appear in ot ipaddr) it has almost certainly formed its own single-node partition after losing contact with the rest of the mesh. Same credentials, different partition, so your border router is no longer in the same network and the device is unreachable. Confirm by comparing the Partition ID from ot leaderdata against the border router's ot-ctl leaderdata, and with ot router table (an isolated node lists only itself). This is the range problem in its most extreme form, and the fix is physical: move the device closer or add a router between it and the border router. For a router or leader (which have no parent), gauge links with ot neighbor table and ot router table (RSSI and LQ In/Out) rather than ot parent.

For a node that is a router or leader, these two tables are how you judge radio health. Here is the XIAO once it had rejoined the real mesh (a router again, not an isolated leader):

uart:~$ ot neighbor table
| Role | RLOC16 | Age | Avg RSSI | Last RSSI | LQ In |R|D|N| Extended MAC     | Version |
+------+--------+-----+----------+-----------+-------+-+-+-+------------------+---------+
|   C  | 0xa801 |   7 |      -74 |       -76 |     3 |0|0|0| 5e81111aa76aea2a |       4 |
|   R  | 0x0400 |  39 |      -86 |       -90 |     2 |1|1|1| a2e06b1251a556b0 |       4 |
|   R  | 0xd400 |  19 |      -75 |       -77 |     3 |1|1|1| 32070c64dd7eed7a |       4 |
Done

uart:~$ ot router table
| ID | RLOC16 | Next Hop | Path Cost | LQ In | LQ Out | Age | Extended MAC     | Link |
+----+--------+----------+-----------+-------+--------+-----+------------------+------+
|  1 | 0x0400 |       53 |         1 |     2 |      0 |  16 | a2e06b1251a556b0 |    1 |
|  8 | 0x2000 |       53 |         1 |     0 |      0 |   8 | 1686035dda729f80 |    0 |
| 27 | 0x6c00 |       53 |         2 |     0 |      0 |  24 | 0000000000000000 |    0 |
| 42 | 0xa800 |       63 |         0 |     0 |      0 |   0 | e25b494c6b17b7f9 |    0 |
| 53 | 0xd400 |       63 |         0 |     3 |      3 |  14 | 32070c64dd7eed7a |    1 |
Done

How to read them (Note: US spelling neighbor, as OpenThread uses):

  • Avg RSSI / Last RSSI (neighbor table) is the raw received signal in dBm; closer to zero is stronger. As a rough guide, around -75 dBm and better is a solid link, -85 to -90 is getting marginal, and below about -95 starts dropping frames. Here the Nest border router (0xd400) is a healthy -75, while router 0x0400 at -86 is noticeably weaker.
  • LQ In / LQ Out are OpenThread's own 0-3 link-quality grades (3 is best) for frames received and sent. A 3 is a strong link, 1 is barely usable. In the router table an LQ of 0 alongside a Link of 0 means there is no direct radio link to that router at all — the node reaches it multi-hop, which the Next Hop and Path Cost columns describe.
  • Role / R / D / N flags (neighbor table): C is a child and R a router; the three flags mark rx-on-when-idle, full Thread device, and full network data.
  • The XIAO's own entry is 0xa800 (path cost 0, next hop pointing at itself). What you want for a reliable browser connection is a direct neighbour toward your border router showing LQ 3 and an RSSI in the -70s — which is exactly the case for 0xd400 here. If the only links to the border router show LQ 0/1 or very negative RSSI, that is your cue to move the board or add an intermediate router, the fix in the range troubleshooting below.

Which address does the browser use?

That ot ipaddr list looks alarming, but only one of those addresses is the one you want. Here is what each is:

Address Type Use it?
fd5c:2f13:2208:1:… OMR (Off-Mesh-Routable) Yes — this is the browser address
fda5:600f:6486:0:0:ff:fe00:… RLOC (routing locator) No — internal, and it changes
fda5:600f:6486:0:ab3b:… ML-EID (mesh-local) No — only routable inside the mesh
fe80::… Link-local No — valid only on the immediate radio link

The trick to telling them apart: the OMR address sits on the prefix your border router advertises (here fd5c:2f13:2208:1::/64), which is a different prefix from the mesh-local fda5:… one that came out of your dataset. The RLOC is easy to spot because it always contains :ff:fe00:. So the browser address is the fd… one that is neither link-local nor an RLOC.

🚧
Attached to the mesh but no fd… OMR address? If ot ipaddr only ever shows the mesh-local addresses (the …:ff:fe00:… RLOC and one more on the same prefix) plus link-local, and never an address on the border router's advertised prefix, the cause is almost always that SLAAC is not compiled in. An OMR address is auto-configured by SLAAC, and Zephyr leaves that module off unless you ask for it, which is exactly why CONFIG_OPENTHREAD_SLAAC=y is in the prj.conf above. Without it the device attaches perfectly but stays unreachable from your browser. Confirm the prefix is actually being advertised with ot netdata show (look for a prefix carrying the a SLAAC flag); if it is there but you have no address on it, enable SLAAC, rebuild, and reflash.

Browsing to the device

With the border router advertising the Thread prefix onto your network, a PC on the same network can reach the XIAO's OMR address. Before opening a browser, confirm the path with a ping from your PC. A working ping proves routing end to end and separates "the network is broken" from "the web server is broken":

C:\> ping fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7

Pinging fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7 with 32 bytes of data:
Reply from fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7: time=30ms
Reply from fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7: time=29ms
Reply from fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7: time=57ms
Reply from fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7: time=26ms

Ping statistics for fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7:
    Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
    Minimum = 26ms, Maximum = 57ms, Average = 35ms

Round-trip times of a few tens of milliseconds across a couple of 802.15.4 hops are normal. Once the ping replies, point your browser at the same address. IPv6 literals go in square brackets in a URL:

http://[fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7]/
Figure 3: The Web Browser with the full IPv6 address in the address bar. A press of Turn On/Turn Off changes the physical on-board LED state.
molloyd@ele:~$ curl -g -6 "http://[fd2d:98e1:a8ab:1:5fc5:c381:4cb5:ddb7]/"
<!DOCTYPE html><html data-theme="dark"><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>XIAO nRF52840</title><link rel="stylesheet" href="https://derekmolloy.ie/assets/built/theme.css"></head><body class="dm"><h1>Hello from Zephyr on the XIAO nRF52840</h1><p>User LED is currently <b>OFF</b>.</p><form action="/toggle" method="get"><button type="submit" style="font-size:1.5rem;padding:0.6rem 1.4rem">Turn ON</button></form><form action="/theme" method="get"><button type="submit" style="font-size:1.5rem;padding:0.6rem 1.4rem;margin-top:0.6rem">Light mode</button></form><p>Served by Zephyr over Thread. See [<a href="https://derekmolloy.ie/">derekmolloy.ie</a>]</p></body></html>

The raw website call using curl

You should see the page, with the LED state and the two buttons. Clicking Turn ON drives the LED, Turn OFF clears it, and Light mode / Dark mode flips the theme, each click reloading the page with the new state.

💡
The OMR address can change. The Off-Mesh-Routable prefix is chosen by the border routers, and it can change when the network re-forms, when a device rejoins after being isolated, or when a different border router takes over advertising it. If you followed this guide top to bottom you will have noticed the prefix here (fd2d:98e1:a8ab:1::/64) differs from the one shown during commissioning (fd5c:…) for exactly that reason. The lesson: never hard-code the address — re-read ot ipaddr on the device and use whichever OMR address it currently holds.

If the page will not load Reaching a Thread device from a PC depends on IPv6 routing being healthy end to end. Work through it in this order:

  1. Ping the OMR address from your PC first (as shown above; add -6 on some systems). If the ping times out, it is a network/routing problem, and you should keep going down this list. If the ping succeeds but the browser does not, the network is fine and the fault is the web server or the browser. Isolate them with a direct request: curl -g -6 "http://[<omr-address>]/". If curl returns the HTML, the server works and the browser is at fault (some browsers open speculative pre-connections that a one-client-at-a-time server can stall on. You can try a hard reload or another browser). If curl also fails, the server is not listening: check the serial boot log for HTTP server listening on [::]:80 and any error printed above it (LED device not ready, bind() failed).
  2. Connection refused means the server is not listening. This is different from a timeout: a fast refusal is a TCP reset, which proves the device is reachable (the stack is up) but nothing is bound to port 80. The usual cause is that main() never reached its accept() loop. Watch for the trap that a truncated or paste-mangled main.c still builds and boots: if the file lost its main() (or main() is present but a broken string literal stopped compilation of an earlier build you never re-flashed), Zephyr links its own empty weak main, so the board runs happily with no server and no error at all. If you see none of your own printk/log lines at boot, suspect this first. Then verify the source is complete (render_page once, ends with main()), rebuild, and confirm you flashed the freshly built .uf2 to the correct drive letter.
  3. Confirm you are using the OMR address, not the RLOC or a mesh-local one (see the table above). Only the border-router-advertised prefix is routable from your PC.
  4. Check the border router is actually routing. On an OTBR: ot-ctl br state should be running, ot-ctl netdata show should list the OMR prefix and a route, and the host needs net.ipv6.conf.all.forwarding=1. These are exactly the points covered in the border-router guide.
  5. Suspect signal and mesh position. A ping that simply times out, on a device you know is configured correctly, often just means a weak radio link. The XIAO's onboard PCB antenna is tiny, and if the board sits at the far edge of your mesh the link can be too lossy to carry TCP even when the address is right. Move the XIAO closer to the border router or another Thread router, or add a mains-powered Thread router in between to extend the mesh. Gauge the link on the XIAO with ot neighbor table and ot router table (RSSI and LQ In/Out); ot parent also shows the uplink quality, but only while the node is a child. The clearest sign of all that this is range and not configuration is the node drifting to leader state with the …:ff:fe00:fc00 anycast address — that means it has split off into its own partition entirely (see the ot parent note in the joining section above).
Figure 4: The €7 Grillplats from IKEA is an excellent Thread-powered range extender/repeater with Matter support. See: Grillplats in the IKEA Store. Shown also are the IKEA Myggspray motion sensor, and the IKEA Timmerflotte temperature/humdity sensor, both with Matter support.
What about Matter? The device we built is a plain Thread node: it speaks raw HTTP over IPv6, which is perfect for learning and for bespoke projects, but it is not a Matter device. It is worth being clear on the distinction, because the two are easily conflated. Thread is only the transport, i.e., the low-power IPv6 mesh that carries the packets. Matter is the application layer that sits on top of it: a standardised data model (devices, clusters, attributes) plus a commissioning and security scheme, so that ecosystems like Home Assistant, Apple Home, Google Home, and Alexa can discover and control the device interoperably without any custom code on their side. To turn this board into a Matter-over-Thread device you would replace our hand-written HTTP server with the Matter stack (the open-source Matter SDK, which Zephyr integrates and which Nordic supports for the nRF52840 through its nRF Connect SDK). The board would then be paired with a QR or numeric commissioning code rather than reached at a bare IPv6 address, and it would show up as a proper device tile in your smart-home app. That is a considerably larger project, but the Thread groundwork in this guide (a border router and a commissioned nRF52840 on the mesh) is exactly the foundation it builds on.

Conclusion

  • The XIAO nRF52840 has no Wi-Fi, so "networked device" here means Thread: a low-power IPv6 mesh that reaches your browser through a border router.
  • Zephyr is a full RTOS, not an async runtime. You get pre-emptive threads, devicetree, Kconfig, and a maintained networking stack, at the cost of a heavier build system and manual memory safety.
  • The application code is very similar to the Embassy version: open a socket, accept, read, route, render, close. The concurrency happens underneath you in the Thread stack's own threads, rather than in cooperative .await points in your own task.
  • The biggest practical hurdle is not the firmware but the infrastructure: a working Thread border router is a hard prerequisite for the networking half of this project.

In the next guide we will return to this exact board and rebuild the server in Rust, so we can lay all three side by side: Embassy on the ESP32-C3, Zephyr in C on the XIAO, and Rust on Zephyr or bare metal on the same XIAO.

Good luck! 🍀