dioxus-cloudflare 0.7.1

Bridge between Dioxus server functions and Cloudflare Workers
Documentation

dioxus-cloudflare

The missing bridge between Dioxus server functions and Cloudflare Workers.

crates.io docs.rs license


What It Does

Write a #[server] function once. It runs on Cloudflare Workers. The client calls it like a normal async function. No manual routing, no manual serialization, no duplicated endpoints.

// shared crate — server function
use dioxus::prelude::*;
use dioxus_cloudflare::prelude::*;

#[server]
pub async fn get_user(id: String) -> Result<User, ServerFnError> {
    let db = cf::d1("DB")?;
    db.prepare("SELECT * FROM users WHERE id = ?")
        .bind(&[id.into()])?
        .first::<User>(None)
        .await
        .cf()?
        .ok_or_else(|| ServerFnError::new("Not found"))
}
// client component — just call it
let user = get_user("abc".into()).await;

Architecture

Client WASM                    Cloudflare Worker
┌──────────┐    fetch()    ┌─────────────────────┐
│ #[server] │ ───────────▶ │ handle(req, env)     │
│ generates │              │   ↓ set_context()    │
│ POST to   │              │   ↓ worker→http req  │
│ /api/...  │              │   ↓ Axum dispatch    │
│           │ ◀─ stream ─  │   ↓ http→worker resp │
└──────────┘               └─────────────────────┘

Request Flow

  1. Client calls get_user(id) — Dioxus serializes args, sends POST to /api/get_user
  2. Worker #[event(fetch)] receives the request
  3. dioxus_cloudflare::handle(req, env) is called:
    • Stores Env in thread-local (cf::env() becomes available)
    • Stores raw Request in thread-local (cf::req() becomes available)
    • Converts worker::Requesthttp::Request
    • Dispatches through the Dioxus Axum router (axum_core feature)
    • Converts http::Responseworker::Response (streaming via ReadableStream)
  4. Worker returns the response

Why Thread-Local Works

Cloudflare Workers run one request per isolate at a time (single-threaded WASM). There is no concurrent access to thread-locals within a single Worker invocation.

The Crate Provides

Export What It Does
cf::d1(name) D1 database — env + binding + error conversion in one call
cf::kv(name) Workers KV namespace
cf::r2(name) R2 bucket
cf::durable_object(name) Durable Object namespace
cf::queue(name) Queue producer (requires queue feature)
cf::env() Full Worker Env — for bindings without a shorthand
cf::req() Raw worker::Request — headers, IP
cf::cookie(name) Read a named cookie from the request
cf::cookies() Read all cookies from the request
cf::set_cookie() Set an HttpOnly/Secure auth cookie (secure defaults)
cf::set_cookie_with() Set a cookie with custom options (builder pattern)
cf::clear_cookie() Clear a cookie (logout)
handle(req, env) Main entry point — wire this into #[event(fetch)]
Handler Builder with before/after middleware hooks
CfError Newtype for worker::ErrorServerFnError conversion
CfResultExt .cf() method on Result<T, worker::Error> and Result<T, KvError>

Prerequisites

This crate requires a patched version of dioxus-server that adds wasm32 target support. Add the following to your workspace Cargo.toml:

[patch.crates-io]

dioxus-server = { git = "https://github.com/JaffeSystems/dioxus-server-cf.git" }

This is necessary because upstream dioxus-server 0.7.3 does not compile for wasm32-unknown-unknown. The patch applies minimal cfg-gating to make it compatible with Cloudflare Workers.

Usage

Worker Entry Point

use worker::*;
use dioxus_cloudflare::prelude::*;

// Import server functions so they register with inventory
use shared::server_fns::*;

extern "C" { fn __wasm_call_ctors(); }

#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    // Required: initialize inventory for #[server] function registration
    // SAFETY: Called once per cold start. inventory crate needs this in WASM.
    unsafe { __wasm_call_ctors(); }

    dioxus_cloudflare::handle(req, env).await
}

Middleware Hooks

Use [Handler] for before/after middleware without touching bridge internals.

CORS headers on all responses:

use worker::*;
use dioxus_cloudflare::Handler;

#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    unsafe { __wasm_call_ctors(); }

    Handler::new()
        .after(|resp| {
            resp.headers_mut().set("Access-Control-Allow-Origin", "*")?;
            Ok(())
        })
        .handle(req, env)
        .await
}

Auth check (short-circuit unauthorized requests):

Handler::new()
    .before(|req| {
        if req.headers().get("Authorization")?.is_none() {
            return Ok(Some(Response::error("Unauthorized", 401)?));
        }
        Ok(None) // continue to server functions
    })
    .handle(req, env)
    .await

Before hooks run after context is set (cf::env(), cf::d1(), etc. work). Return Ok(None) to continue, Ok(Some(resp)) to short-circuit. After hooks run on all responses (including short-circuited ones) and can modify headers.

SSR (Server-Side Rendering)

Render Dioxus components to HTML at the edge. Requires the ssr feature.

When the Axum router returns 404 and the request accepts text/html, the handler renders your app component and returns the HTML. Non-HTML requests (JS, CSS, WASM, JSON) pass through normally.

Minimal SSR (default HTML shell, no client JS):

Handler::new()
    .with_ssr(App)
    .handle(req, env)
    .await

SSR with custom index.html (SPA takeover after first paint):

Handler::new()
    .with_ssr(App)
    .with_index_html(include_str!("path/to/index.html"))?
    .handle(req, env)
    .await

The custom index.html must contain an element with id="main" — rendered component output is inserted at that point.

Suspense is supported: wait_for_suspense() resolves server futures during SSR, so components that call #[server] functions via use_server_future will have their data ready in the initial HTML.

SSR with Hydration

SSR always renders with hydration markers (data-node-hydration attributes) and injects serialized hydration data. When the client WASM is built with hydrate(true), it reuses the server-rendered DOM instead of re-rendering — providing instant first paint with no flash.

Worker (server):

Handler::new()
    .with_ssr(App)
    .with_index_html(include_str!("path/to/index.html"))?
    .handle(req, env)
    .await

Client WASM (must render the same component):

The client must enable the fullstack feature on dioxus (which activates dioxus-web/hydrate):

# Cargo.toml

[dependencies]

dioxus = { version = "=0.7.3", features = ["web", "fullstack"] }

fn main() {
    dioxus::launch(App);
}

Important: Do not use ? on use_server_future in hydrated components. The ? operator suspends the component if the resource isn't immediately ready, which creates a VirtualDom/DOM tree mismatch and crashes the hydration walker. Instead, match on the Result:

#[component]
fn App() -> Element {
    let data_text = match use_server_future(get_data) {
        Ok(resource) => match &*resource.read() {
            Some(Ok(s)) => s.clone(),
            Some(Err(e)) => format!("Error: {e}"),
            None => "Loading...".into(),
        },
        Err(_) => "Loading...".into(),
    };
    rsx! { p { "{data_text}" } }
}

Build order:

  1. dx build --release — builds client WASM + index.html
  2. cargo build --release --target wasm32-unknown-unknown -p your-worker — worker includes index.html via include_str!

Server Function (Shared Crate)

use dioxus::prelude::*;
use dioxus_cloudflare::prelude::*;

#[server]
pub async fn create_order(items: Vec<Item>) -> Result<Order, ServerFnError> {
    let db = cf::d1("DB")?;

    db.prepare("INSERT INTO orders (items, total) VALUES (?, ?)")
        .bind(&[serde_json::to_string(&items)?.into(), total.into()])?
        .run()
        .await
        .cf()?;

    Ok(Order { items, total, status: "confirmed".into() })
}

Client Component

use dioxus::prelude::*;
use shared::server_fns::create_order;

#[component]
fn OrderButton(items: Vec<Item>) -> Element {
    let order = use_resource(move || {
        let items = items.clone();
        async move { create_order(items).await }
    });

    match &*order.read() {
        Some(Ok(o)) => rsx! { p { "Order confirmed: {o.status}" } },
        Some(Err(e)) => rsx! { p { "Error: {e}" } },
        None => rsx! { p { "Placing order..." } },
    }
}

Crate Structure

src/
├── lib.rs          # Public API: cf module, handle(), re-exports
├── bindings.rs     # Typed binding shorthands: d1(), kv(), r2(), durable_object(), queue()
├── context.rs      # Thread-local Env + Request storage
├── handler.rs      # handle() — Worker↔Axum request/response bridge
├── cookie.rs       # Cookie read/write helpers + CookieBuilder
├── error.rs        # CfError newtype + CfResultExt trait
└── prelude.rs      # Convenience re-exports

Implementation Status

Working end-to-end

  • Thread-local context (cf::env(), cf::req())

  • Typed binding shorthands (cf::d1(), cf::kv(), cf::r2(), cf::durable_object(), cf::queue())

  • Durable Objectscf::durable_object() shorthand for namespace access

  • Queuescf::queue() shorthand for producer access (requires queue feature)

  • Request headers — read via cf::req() inside server functions

  • Cookie reading (cf::cookie(), cf::cookies())

  • Cookie writing (cf::set_cookie(), cf::clear_cookie()) — queue-based, applied to response automatically

  • Configurable cookie builder (cf::set_cookie_with()) — custom SameSite, Domain, Path, Max-Age, etc.

  • Error bridge (CfError, .cf()) — covers both worker::Error and KvError

  • Streaming responsesTextStream, ByteStream, and other streaming payloads via ReadableStream

  • Request/response conversion (worker::Requesthttp::Request)

  • dispatch() — Axum router with ServerFunction::collect() + .oneshot()

  • __wasm_call_ctors + inventory initialization

  • D1 — read/write queries via cf::d1("DB")

  • Workers KV — put/get/delete via cf::kv("KV")

  • R2 — put/get/delete via cf::r2("BUCKET")

  • Static assets via wrangler [assets] + run_worker_first = ["/api/*"]

  • Middleware hooksHandler builder with before/after hooks for auth, CORS, custom headers

  • SSR inside Workers — render Dioxus components to HTML at the edge (requires ssr feature)

  • Hydration — client reuses server-rendered DOM instead of re-rendering (SSR always emits hydration markers)

Streaming

Response bodies are streamed via ReadableStreamhandler.rs wraps axum::body::Body as a TryStream and pipes it through ResponseBuilder::from_stream(). Server functions returning TextStream, ByteStream, JsonStream, etc. stream chunks directly to the client without buffering the full response into memory.

Roadmap

Near-term

  • Template project / cargo generate — scaffolding for new dioxus-cloudflare projects with wrangler config, shared/web/worker crates, and build scripts
  • Remove dioxus-server patch requirement — upstream the wasm32 cfg-gating to Dioxus core so users don't need [patch.crates-io]

Ideas / Exploration

  • Streaming SSR — send HTML chunks as suspense resolves via ReadableStream
  • Session middleware — built-in session management backed by KV or D1 with cookie-based session IDs
  • WebSocket support — Durable Object + WebSocket upgrade for real-time Dioxus apps
  • Service bindings — call other Workers from server functions via cf::env().service()
  • AI bindingscf::env().ai() for Workers AI inference from server functions
  • Secretscf::env().secret() for accessing encrypted environment variables
  • Wrangler plugin — automate the build pipeline (cargo build → wasm-bindgen → shim generation) without manual wrangler.toml [build] commands

Optional Features

Feature Enables
queue cf::queue() shorthand (activates worker/queue)
ssr Server-side rendering via Handler::with_ssr() (adds dioxus-ssr, dioxus-history)

Dependencies

dioxus = { version = "=0.7.3", features = ["fullstack"] }

worker = { version = "0.4", features = ["http"] }

axum = { version = "0.8", default-features = false }

http = "1"