diff --git a/Cargo.lock b/Cargo.lock
index 1e96dbcad8..fd2e87ae4d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3916,6 +3916,8 @@ dependencies = [
  "dioxus-cli-config",
  "dioxus-desktop",
  "dioxus-devtools",
+ "dioxus-fullstack-hooks",
+ "dioxus-fullstack-protocol",
  "dioxus-history",
  "dioxus-interpreter-js",
  "dioxus-isrg",
@@ -3949,6 +3951,30 @@ dependencies = [
  "web-sys",
 ]
 
+[[package]]
+name = "dioxus-fullstack-hooks"
+version = "0.6.3"
+dependencies = [
+ "dioxus-core",
+ "dioxus-fullstack",
+ "dioxus-fullstack-protocol",
+ "dioxus-hooks",
+ "dioxus-signals",
+ "futures-channel",
+ "serde",
+]
+
+[[package]]
+name = "dioxus-fullstack-protocol"
+version = "0.6.3"
+dependencies = [
+ "base64 0.22.1",
+ "ciborium",
+ "dioxus-core",
+ "serde",
+ "tracing",
+]
+
 [[package]]
 name = "dioxus-history"
 version = "0.6.3"
@@ -4181,6 +4207,7 @@ dependencies = [
  "criterion",
  "dioxus",
  "dioxus-cli-config",
+ "dioxus-fullstack-hooks",
  "dioxus-history",
  "dioxus-lib",
  "dioxus-router-macro",
@@ -4295,13 +4322,13 @@ name = "dioxus-web"
 version = "0.6.3"
 dependencies = [
  "async-trait",
- "ciborium",
  "dioxus",
  "dioxus-cli-config",
  "dioxus-core",
  "dioxus-core-types",
  "dioxus-devtools",
  "dioxus-document",
+ "dioxus-fullstack-protocol",
  "dioxus-history",
  "dioxus-html",
  "dioxus-interpreter-js",
@@ -5228,6 +5255,8 @@ version = "0.1.0"
 dependencies = [
  "axum 0.7.9",
  "dioxus",
+ "http 1.2.0",
+ "reqwest 0.12.9",
  "serde",
  "tokio",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index e6dd71b6d6..54dde5d540 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,6 +38,8 @@ members = [
     "packages/document",
     "packages/extension",
     "packages/fullstack",
+    "packages/fullstack-hooks",
+    "packages/fullstack-protocol",
     "packages/generational-box",
     "packages/history",
     "packages/hooks",
@@ -102,7 +104,7 @@ members = [
     "packages/playwright-tests/suspense-carousel",
     "packages/playwright-tests/nested-suspense",
     "packages/playwright-tests/cli-optimization",
-    "packages/playwright-tests/wasm-split-harness",
+    "packages/playwright-tests/wasm-split-harness"
 ]
 
 [workspace.package]
@@ -141,6 +143,8 @@ dioxus-cli-opt = { path = "packages/cli-opt", version = "0.6.2" }
 dioxus-devtools = { path = "packages/devtools", version = "0.6.2" }
 dioxus-devtools-types = { path = "packages/devtools-types", version = "0.6.2" }
 dioxus-fullstack = { path = "packages/fullstack", version = "0.6.2" }
+dioxus-fullstack-hooks = { path = "packages/fullstack-hooks", version = "0.6.3" }
+dioxus-fullstack-protocol = { path = "packages/fullstack-protocol", version = "0.6.3" }
 dioxus_server_macro = { path = "packages/server-macro", version = "0.6.2", default-features = false }
 dioxus-dx-wire-format = { path = "packages/dx-wire-format", version = "0.6.2" }
 dioxus-logger = { path = "packages/logger", version = "0.6.2" }
diff --git a/examples/fullstack-router/Cargo.toml b/examples/fullstack-router/Cargo.toml
index b635ed28e5..69d426b595 100644
--- a/examples/fullstack-router/Cargo.toml
+++ b/examples/fullstack-router/Cargo.toml
@@ -9,11 +9,13 @@ publish = false
 [dependencies]
 dioxus = { workspace = true, features = ["fullstack", "router"] }
 axum = { workspace = true, optional = true }
-tokio = {workspace = true, features = ["full"], optional = true }
+tokio = { workspace = true, features = ["full"], optional = true }
 serde = { version = "1.0.159", features = ["derive"] }
+reqwest = { workspace = true, features = ["json"] }
+http = { workspace = true, optional = true }
 
 [features]
 default = []
-server = ["axum", "dioxus/server"]
+server = ["axum", "dioxus/server", "dep:tokio", "dep:http"]
 web = ["dioxus/web"]
 
diff --git a/examples/fullstack-router/src/main.rs b/examples/fullstack-router/src/main.rs
index af691f642a..a010f1b473 100644
--- a/examples/fullstack-router/src/main.rs
+++ b/examples/fullstack-router/src/main.rs
@@ -8,10 +8,9 @@ use dioxus::prelude::*;
 
 fn main() {
     dioxus::LaunchBuilder::new()
-        .with_cfg(server_only!(ServeConfig::builder().incremental(
-            IncrementalRendererConfig::default()
-                .invalidate_after(std::time::Duration::from_secs(120)),
-        )))
+        .with_cfg(server_only!(
+            ServeConfig::builder().enable_out_of_order_streaming()
+        ))
         .launch(app);
 }
 
@@ -24,62 +23,97 @@ enum Route {
     #[route("/")]
     Home {},
 
-    #[route("/blog/:id/")]
-    Blog { id: i32 },
+    #[route("/:breed")]
+    Breed { breed: String },
 }
 
 #[component]
-fn Blog(id: i32) -> Element {
+fn Home() -> Element {
     rsx! {
-        Link { to: Route::Home {}, "Go to counter" }
-        table {
-            tbody {
-                for _ in 0..id {
-                    tr {
-                        for _ in 0..id {
-                            td { "hello world!" }
-                        }
-                    }
-                }
-            }
+        Link { to: Route::Breed { breed: "hound".to_string() }, "Hound" }
+    }
+}
+
+#[component]
+fn Breed(breed: String) -> Element {
+    rsx! {
+        BreedGallery { breed: "{breed}", slow: false }
+        SuspenseBoundary {
+            fallback: |_| rsx! { "Loading..." },
+            DoesNotSuspend {}
+            BreedGallery { breed, slow: true }
         }
     }
 }
 
 #[component]
-fn Home() -> Element {
-    let mut count = use_signal(|| 0);
-    let mut text = use_signal(|| "...".to_string());
+fn DoesNotSuspend() -> Element {
+    rsx! { "404" }
+}
+
+#[derive(serde::Deserialize, serde::Serialize)]
+struct BreedResponse {
+    message: Vec<String>,
+}
+
+#[component]
+fn BreedGallery(breed: ReadOnlySignal<String>, slow: bool) -> Element {
+    // use_server_future is very similar to use_resource, but the value returned from the future
+    // must implement Serialize and Deserialize and it is automatically suspended
+    let response = use_server_future(move || async move {
+        if slow {
+            #[cfg(feature = "server")]
+            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
+        }
+        #[cfg(feature = "server")]
+        {
+            use http::StatusCode;
+            let context = server_context();
+            let mut write = context.response_parts_mut();
+            write.status = StatusCode::NOT_FOUND;
+            write.extensions.insert("error???");
+            write.version = http::Version::HTTP_2;
+            write
+                .headers
+                .insert("x-custom-header", http::HeaderValue::from_static("hello"));
+        }
+        // The future will run on the server during SSR and then get sent to the client
+        reqwest::Client::new()
+            .get(format!("https://dog.ceo/api/breed/{breed}/images"))
+            .send()
+            .await
+            // reqwest::Result does not implement Serialize, so we need to map it to a string which
+            // can be serialized
+            .map_err(|err| err.to_string())?
+            .json::<BreedResponse>()
+            .await
+            .map_err(|err| err.to_string())
+        // use_server_future calls `suspend` internally, so you don't need to call it manually, but you
+        // do need to bubble up the suspense variant with `?`
+    })?;
+
+    // If the future was still pending, it would have returned suspended with the `?` above
+    // we can unwrap the None case here to get the inner result
+    let response_read = response.read();
+    let response = response_read.as_ref().unwrap();
 
+    // Then you can just handle the happy path with the resolved future
     rsx! {
-        Link { to: Route::Blog { id: count() }, "Go to blog" }
         div {
-            h1 { "High-Five counter: {count}" }
-            button { onclick: move |_| count += 1, "Up high!" }
-            button { onclick: move |_| count -= 1, "Down low!" }
-            button {
-                onclick: move |_| async move {
-                    if let Ok(data) = get_server_data().await {
-                        println!("Client received: {}", data);
-                        text.set(data.clone());
-                        post_server_data(data).await.unwrap();
+            display: "flex",
+            flex_direction: "row",
+            match response {
+                Ok(urls) => rsx! {
+                    for image in urls.message.iter().take(3) {
+                        img {
+                            src: "{image}",
+                            width: "100px",
+                            height: "100px",
+                        }
                     }
                 },
-                "Run server function!"
+                Err(err) => rsx! { "Failed to fetch response: {err}" },
             }
-            "Server said: {text}"
         }
     }
 }
-
-#[server(PostServerData)]
-async fn post_server_data(data: String) -> Result<(), ServerFnError> {
-    println!("Server received: {}", data);
-
-    Ok(())
-}
-
-#[server(GetServerData)]
-async fn get_server_data() -> Result<String, ServerFnError> {
-    Ok("Hello from the server!".to_string())
-}
diff --git a/packages/core/src/global_context.rs b/packages/core/src/global_context.rs
index 94f60ac38d..cb4277a240 100644
--- a/packages/core/src/global_context.rs
+++ b/packages/core/src/global_context.rs
@@ -1,3 +1,4 @@
+use crate::prelude::SuspenseContext;
 use crate::runtime::RuntimeError;
 use crate::{innerlude::SuspendedFuture, runtime::Runtime, CapturedError, Element, ScopeId, Task};
 use std::future::Future;
@@ -40,6 +41,13 @@ pub fn throw_error(error: impl Into<CapturedError> + 'static) {
         .throw_error(error)
 }
 
+/// Get the suspense context the current scope is in
+pub fn suspense_context() -> Option<SuspenseContext> {
+    current_scope_id()
+        .unwrap_or_else(|e| panic!("{}", e))
+        .suspense_context()
+}
+
 /// Consume context from the current scope
 pub fn try_consume_context<T: 'static + Clone>() -> Option<T> {
     Runtime::with_current_scope(|cx| cx.consume_context::<T>())
diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs
index 97c5b0d947..3407814ab7 100644
--- a/packages/core/src/lib.rs
+++ b/packages/core/src/lib.rs
@@ -96,14 +96,14 @@ pub mod prelude {
         fc_to_builder, generation, has_context, needs_update, needs_update_any, parent_scope,
         provide_context, provide_error_boundary, provide_root_context, queue_effect, remove_future,
         schedule_update, schedule_update_any, spawn, spawn_forever, spawn_isomorphic, suspend,
-        throw_error, try_consume_context, use_after_render, use_before_render, use_drop, use_hook,
-        use_hook_with_cleanup, with_owner, AnyValue, Attribute, Callback, Component,
-        ComponentFunction, Context, Element, ErrorBoundary, ErrorContext, Event, EventHandler,
-        Fragment, HasAttributes, IntoAttributeValue, IntoDynNode, OptionStringFromMarker,
-        Properties, ReactiveContext, RenderError, Runtime, RuntimeGuard, ScopeId, ScopeState,
-        SuperFrom, SuperInto, SuspendedFuture, SuspenseBoundary, SuspenseBoundaryProps,
-        SuspenseContext, SuspenseExtension, Task, Template, TemplateAttribute, TemplateNode, VNode,
-        VNodeInner, VirtualDom,
+        suspense_context, throw_error, try_consume_context, use_after_render, use_before_render,
+        use_drop, use_hook, use_hook_with_cleanup, with_owner, AnyValue, Attribute, Callback,
+        Component, ComponentFunction, Context, Element, ErrorBoundary, ErrorContext, Event,
+        EventHandler, Fragment, HasAttributes, IntoAttributeValue, IntoDynNode,
+        OptionStringFromMarker, Properties, ReactiveContext, RenderError, Runtime, RuntimeGuard,
+        ScopeId, ScopeState, SuperFrom, SuperInto, SuspendedFuture, SuspenseBoundary,
+        SuspenseBoundaryProps, SuspenseContext, SuspenseExtension, Task, Template,
+        TemplateAttribute, TemplateNode, VNode, VNodeInner, VirtualDom,
     };
 }
 
diff --git a/packages/core/src/runtime.rs b/packages/core/src/runtime.rs
index d557cd2a02..1855ea09da 100644
--- a/packages/core/src/runtime.rs
+++ b/packages/core/src/runtime.rs
@@ -116,6 +116,15 @@ impl Runtime {
         result
     }
 
+    /// Run a closure with the rendering flag set to false
+    pub(crate) fn while_not_rendering<T>(&self, f: impl FnOnce() -> T) -> T {
+        let previous = self.rendering.get();
+        self.rendering.set(false);
+        let result = f();
+        self.rendering.set(previous);
+        result
+    }
+
     /// Create a scope context. This slab is synchronized with the scope slab.
     pub(crate) fn create_scope(&self, context: Scope) {
         let id = context.id;
diff --git a/packages/core/src/scope_context.rs b/packages/core/src/scope_context.rs
index 89e31c4389..d57b9706d1 100644
--- a/packages/core/src/scope_context.rs
+++ b/packages/core/src/scope_context.rs
@@ -447,7 +447,12 @@ impl Scope {
         let mut hooks = self.hooks.try_borrow_mut().expect("The hook list is already borrowed: This error is likely caused by trying to use a hook inside a hook which violates the rules of hooks.");
 
         if cur_hook >= hooks.len() {
-            hooks.push(Box::new(initializer()));
+            Runtime::with(|rt| {
+                rt.while_not_rendering(|| {
+                    hooks.push(Box::new(initializer()));
+                });
+            })
+            .unwrap()
         }
 
         self.use_hook_inner::<State>(hooks, cur_hook)
@@ -620,4 +625,9 @@ impl ScopeId {
     pub fn throw_error(self, error: impl Into<CapturedError> + 'static) {
         throw_into(error, self)
     }
+
+    /// Get the suspense context the current scope is in
+    pub fn suspense_context(&self) -> Option<SuspenseContext> {
+        Runtime::with_scope(*self, |cx| cx.suspense_boundary.suspense_context().cloned()).unwrap()
+    }
 }
diff --git a/packages/core/src/suspense/component.rs b/packages/core/src/suspense/component.rs
index cd96261383..8e3eb62a46 100644
--- a/packages/core/src/suspense/component.rs
+++ b/packages/core/src/suspense/component.rs
@@ -360,7 +360,7 @@ impl SuspenseBoundaryProps {
                     SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
                         .unwrap();
                 suspense_context.take_suspended_nodes();
-                mark_suspense_resolved(dom, scope_id);
+                mark_suspense_resolved(&suspense_context, dom, scope_id);
 
                 nodes_created
             };
@@ -437,6 +437,9 @@ impl SuspenseBoundaryProps {
             let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
             props.children.clone_from(&children);
             scope_state.last_rendered_node = Some(children);
+
+            // Run any closures that were waiting for the suspense to resolve
+            suspense_context.run_resolved_closures(&dom.runtime);
         })
     }
 
@@ -571,7 +574,7 @@ impl SuspenseBoundaryProps {
                     // Set the last rendered node to the new children
                     dom.scopes[scope_id.0].last_rendered_node = Some(new_children);
 
-                    mark_suspense_resolved(dom, scope_id);
+                    mark_suspense_resolved(&suspense_context, dom, scope_id);
                 }
             }
         })
@@ -579,8 +582,14 @@ impl SuspenseBoundaryProps {
 }
 
 /// Move to a resolved suspense state
-fn mark_suspense_resolved(dom: &mut VirtualDom, scope_id: ScopeId) {
+fn mark_suspense_resolved(
+    suspense_context: &SuspenseContext,
+    dom: &mut VirtualDom,
+    scope_id: ScopeId,
+) {
     dom.resolved_scopes.push(scope_id);
+    // Run any closures that were waiting for the suspense to resolve
+    suspense_context.run_resolved_closures(&dom.runtime);
 }
 
 /// Move from a resolved suspense state to an suspended state
@@ -590,12 +599,12 @@ fn un_resolve_suspense(dom: &mut VirtualDom, scope_id: ScopeId) {
 
 impl SuspenseContext {
     /// Run a closure under a suspense boundary
-    pub fn under_suspense_boundary<O>(&self, runtime: &Runtime, f: impl FnOnce() -> O) -> O {
+    pub(crate) fn under_suspense_boundary<O>(&self, runtime: &Runtime, f: impl FnOnce() -> O) -> O {
         runtime.with_suspense_location(SuspenseLocation::UnderSuspense(self.clone()), f)
     }
 
     /// Run a closure under a suspense placeholder
-    pub fn in_suspense_placeholder<O>(&self, runtime: &Runtime, f: impl FnOnce() -> O) -> O {
+    pub(crate) fn in_suspense_placeholder<O>(&self, runtime: &Runtime, f: impl FnOnce() -> O) -> O {
         runtime.with_suspense_location(SuspenseLocation::InSuspensePlaceholder(self.clone()), f)
     }
 
diff --git a/packages/core/src/suspense/mod.rs b/packages/core/src/suspense/mod.rs
index 082d517226..ca44442f77 100644
--- a/packages/core/src/suspense/mod.rs
+++ b/packages/core/src/suspense/mod.rs
@@ -102,6 +102,7 @@ impl SuspenseContext {
                 id: Cell::new(ScopeId::ROOT),
                 suspended_nodes: Default::default(),
                 frozen: Default::default(),
+                after_suspense_resolved: Default::default(),
             }),
         }
     }
@@ -184,10 +185,26 @@ impl SuspenseContext {
             .find_map(|task| task.suspense_placeholder())
             .map(std::result::Result::Ok)
     }
+
+    /// Run a closure after suspense is resolved
+    pub fn after_suspense_resolved(&self, callback: impl FnOnce() + 'static) {
+        let mut closures = self.inner.after_suspense_resolved.borrow_mut();
+        closures.push(Box::new(callback));
+    }
+
+    /// Run all closures that were queued to run after suspense is resolved
+    pub(crate) fn run_resolved_closures(&self, runtime: &Runtime) {
+        runtime.while_not_rendering(|| {
+            self.inner
+                .after_suspense_resolved
+                .borrow_mut()
+                .drain(..)
+                .for_each(|f| f());
+        })
+    }
 }
 
 /// A boundary that will capture any errors from child components
-#[derive(Debug)]
 pub struct SuspenseBoundaryInner {
     suspended_tasks: RefCell<Vec<SuspendedFuture>>,
     id: Cell<ScopeId>,
@@ -195,6 +212,19 @@ pub struct SuspenseBoundaryInner {
     suspended_nodes: RefCell<Option<VNode>>,
     /// On the server, you can only resolve a suspense boundary once. This is used to track if the suspense boundary has been resolved and if it should be frozen
     frozen: Cell<bool>,
+    /// Closures queued to run after the suspense boundary is resolved
+    after_suspense_resolved: RefCell<Vec<Box<dyn FnOnce()>>>,
+}
+
+impl Debug for SuspenseBoundaryInner {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("SuspenseBoundaryInner")
+            .field("suspended_tasks", &self.suspended_tasks)
+            .field("id", &self.id)
+            .field("suspended_nodes", &self.suspended_nodes)
+            .field("frozen", &self.frozen)
+            .finish()
+    }
 }
 
 /// Provides context methods to [`Result<T, RenderError>`] to show loading indicators for suspended results
diff --git a/packages/fullstack-hooks/Cargo.toml b/packages/fullstack-hooks/Cargo.toml
new file mode 100644
index 0000000000..bd0219fa56
--- /dev/null
+++ b/packages/fullstack-hooks/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "dioxus-fullstack-hooks"
+authors = ["Jonathan Kelley", "Evan Almloff"]
+version = { workspace = true }
+edition = "2021"
+description = "Hooks for serializing futures, values in dioxus-fullstack and other utilities"
+license = "MIT OR Apache-2.0"
+repository = "https://github.com/DioxusLabs/dioxus/"
+homepage = "https://dioxuslabs.com"
+keywords = ["web", "gui", "server"]
+resolver = "2"
+
+[dependencies]
+dioxus-core = { workspace = true }
+dioxus-signals = { workspace = true }
+dioxus-hooks = { workspace = true }
+dioxus-fullstack-protocol = { workspace = true }
+futures-channel = { workspace = true }
+serde = { workspace = true }
+
+[dev-dependencies]
+dioxus-fullstack = { workspace = true }
+
+[features]
+web = []
+server = []
+
+[package.metadata.docs.rs]
+cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
diff --git a/packages/fullstack-hooks/README.md b/packages/fullstack-hooks/README.md
new file mode 100644
index 0000000000..f025fd32c3
--- /dev/null
+++ b/packages/fullstack-hooks/README.md
@@ -0,0 +1,32 @@
+# Dioxus Fullstack Hooks
+
+Dioxus fullstack hooks provides hooks and contexts for [`dioxus-fullstack`](https://crates.io/crates/dioxus-fullstack). Libraries that need to integrate with dioxus-fullstack should rely on this crate instead of the renderer for quicker build times.
+
+## Usage
+
+To start using this crate, you can run the following command:
+
+```bash
+cargo add dioxus-fullstack-hooks
+```
+
+Then you can use hooks like `use_server_future` in your components:
+
+```rust
+use dioxus::prelude::*;
+async fn fetch_article(id: u32) -> String {
+    format!("Article {}", id)
+}
+
+fn App() -> Element {
+    let mut article_id = use_signal(|| 0);
+    // `use_server_future` will spawn a task that runs on the server and serializes the result to send to the client.
+    // The future will rerun any time the
+    // Since we bubble up the suspense with `?`, the server will wait for the future to resolve before rendering
+    let article = use_server_future(move || fetch_article(article_id()))?;
+
+    rsx! {
+        "{article().unwrap()}"
+    }
+}
+```
diff --git a/packages/fullstack-hooks/src/hooks/mod.rs b/packages/fullstack-hooks/src/hooks/mod.rs
new file mode 100644
index 0000000000..4de7ac9679
--- /dev/null
+++ b/packages/fullstack-hooks/src/hooks/mod.rs
@@ -0,0 +1,4 @@
+mod server_cached;
+pub use server_cached::*;
+mod server_future;
+pub use server_future::*;
diff --git a/packages/fullstack/src/hooks/server_cached.rs b/packages/fullstack-hooks/src/hooks/server_cached.rs
similarity index 77%
rename from packages/fullstack/src/hooks/server_cached.rs
rename to packages/fullstack-hooks/src/hooks/server_cached.rs
index c651a155ee..06f2e8a202 100644
--- a/packages/fullstack/src/hooks/server_cached.rs
+++ b/packages/fullstack-hooks/src/hooks/server_cached.rs
@@ -1,4 +1,5 @@
-use dioxus_lib::prelude::use_hook;
+use dioxus_core::prelude::use_hook;
+use dioxus_fullstack_protocol::SerializeContextEntry;
 use serde::{de::DeserializeOwned, Serialize};
 
 /// This allows you to send data from the server to the client. The data is serialized into the HTML on the server and hydrated on the client.
@@ -32,19 +33,21 @@ pub(crate) fn server_cached<O: 'static + Clone + Serialize + DeserializeOwned>(
     value: impl FnOnce() -> O,
     #[allow(unused)] location: &'static std::panic::Location<'static>,
 ) -> O {
+    let serialize = dioxus_fullstack_protocol::serialize_context();
+    #[allow(unused)]
+    let entry: SerializeContextEntry<O> = serialize.create_entry();
     #[cfg(feature = "server")]
     {
-        let serialize = crate::html_storage::serialize_context();
         let data = value();
-        serialize.push(&data, location);
+        entry.insert(&data, location);
         data
     }
     #[cfg(all(not(feature = "server"), feature = "web"))]
     {
-        dioxus_web::take_server_data()
-            .ok()
-            .flatten()
-            .unwrap_or_else(value)
+        match entry.get() {
+            Ok(value) => value,
+            Err(_) => value(),
+        }
     }
     #[cfg(not(any(feature = "server", feature = "web")))]
     {
diff --git a/packages/fullstack/src/hooks/server_future.rs b/packages/fullstack-hooks/src/hooks/server_future.rs
similarity index 85%
rename from packages/fullstack/src/hooks/server_future.rs
rename to packages/fullstack-hooks/src/hooks/server_future.rs
index e2abaeebc3..243dfa4cc2 100644
--- a/packages/fullstack/src/hooks/server_future.rs
+++ b/packages/fullstack-hooks/src/hooks/server_future.rs
@@ -1,4 +1,6 @@
-use dioxus_lib::prelude::*;
+use dioxus_core::prelude::{suspend, use_hook, RenderError};
+use dioxus_hooks::*;
+use dioxus_signals::Readable;
 use serde::{de::DeserializeOwned, Serialize};
 use std::future::Future;
 
@@ -64,27 +66,24 @@ where
     T: Serialize + DeserializeOwned + 'static,
     F: Future<Output = T> + 'static,
 {
-    #[cfg(feature = "server")]
-    let serialize_context = crate::html_storage::use_serialize_context();
+    let serialize_context = use_hook(dioxus_fullstack_protocol::serialize_context);
 
     // We always create a storage entry, even if the data isn't ready yet to make it possible to deserialize pending server futures on the client
-    #[cfg(feature = "server")]
-    let server_storage_entry = use_hook(|| serialize_context.create_entry());
+    #[allow(unused)]
+    let storage_entry: dioxus_fullstack_protocol::SerializeContextEntry<T> =
+        use_hook(|| serialize_context.create_entry());
 
     #[cfg(feature = "server")]
     let caller = std::panic::Location::caller();
 
     // If this is the first run and we are on the web client, the data might be cached
     #[cfg(feature = "web")]
-    let initial_web_result = use_hook(|| {
-        std::rc::Rc::new(std::cell::RefCell::new(Some(
-            dioxus_web::take_server_data::<T>(),
-        )))
-    });
+    let initial_web_result =
+        use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(Some(storage_entry.get()))));
 
     let resource = use_resource(move || {
         #[cfg(feature = "server")]
-        let serialize_context = serialize_context.clone();
+        let storage_entry = storage_entry.clone();
 
         let user_fut = future();
 
@@ -97,10 +96,12 @@ where
             #[cfg(feature = "web")]
             match initial_web_result.take() {
                 // The data was deserialized successfully from the server
-                Some(Ok(Some(o))) => return o,
+                Some(Ok(o)) => return o,
 
                 // The data is still pending from the server. Don't try to resolve it on the client
-                Some(Ok(None)) => std::future::pending::<()>().await,
+                Some(Err(dioxus_fullstack_protocol::TakeDataError::DataPending)) => {
+                    std::future::pending::<()>().await
+                }
 
                 // The data was not available on the server, rerun the future
                 Some(Err(_)) => {}
@@ -114,7 +115,7 @@ where
 
             // If this is the first run and we are on the server, cache the data in the slot we reserved for it
             #[cfg(feature = "server")]
-            serialize_context.insert(server_storage_entry, &out, caller);
+            storage_entry.insert(&out, caller);
 
             out
         }
diff --git a/packages/fullstack-hooks/src/lib.rs b/packages/fullstack-hooks/src/lib.rs
new file mode 100644
index 0000000000..e83a83ebf0
--- /dev/null
+++ b/packages/fullstack-hooks/src/lib.rs
@@ -0,0 +1,7 @@
+#![warn(missing_docs)]
+#![doc = include_str!("../README.md")]
+
+mod hooks;
+pub use hooks::*;
+mod streaming;
+pub use streaming::*;
diff --git a/packages/fullstack-hooks/src/streaming.rs b/packages/fullstack-hooks/src/streaming.rs
new file mode 100644
index 0000000000..5f3e3c011b
--- /dev/null
+++ b/packages/fullstack-hooks/src/streaming.rs
@@ -0,0 +1,105 @@
+use dioxus_core::prelude::try_consume_context;
+use dioxus_signals::{Readable, Signal, Writable};
+
+/// The status of the streaming response
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum StreamingStatus {
+    /// The initial chunk is still being rendered. The http response parts can still be modified with
+    /// [DioxusServerContext::response_parts_mut](https://docs.rs/dioxus-fullstack/0.6.3/dioxus_fullstack/prelude/struct.DioxusServerContext.html#method.response_parts_mut).
+    RenderingInitialChunk,
+    /// The initial chunk has been committed and the response is now streaming. The http response parts
+    /// have already been sent to the client and can no longer be modified.
+    InitialChunkCommitted,
+}
+
+/// The context dioxus fullstack provides for the status of streaming responses on the server
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct StreamingContext {
+    current_status: Signal<StreamingStatus>,
+}
+
+impl Default for StreamingContext {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl StreamingContext {
+    /// Create a new streaming context. You should not need to call this directly. Dioxus fullstack will
+    /// provide this context for you.
+    pub fn new() -> Self {
+        Self {
+            current_status: Signal::new(StreamingStatus::RenderingInitialChunk),
+        }
+    }
+
+    /// Commit the initial chunk of the response. This will be called automatically if you are using the
+    /// dioxus router when the suspense boundary above the router is resolved. Otherwise, you will need
+    /// to call this manually to start the streaming part of the response.
+    ///
+    /// Once this method has been called, the http response parts can no longer be modified.
+    pub fn commit_initial_chunk(&mut self) {
+        self.current_status
+            .set(StreamingStatus::InitialChunkCommitted);
+    }
+
+    /// Get the current status of the streaming response. This method is reactive and will cause
+    /// the current reactive context to rerun when the status changes.
+    pub fn current_status(&self) -> StreamingStatus {
+        *self.current_status.read()
+    }
+}
+
+/// Commit the initial chunk of the response. This will be called automatically if you are using the
+/// dioxus router when the suspense boundary above the router is resolved. Otherwise, you will need
+/// to call this manually to start the streaming part of the response.
+///
+/// On the client, this will do nothing.
+///
+/// # Example
+/// ```rust, no_run
+/// # use dioxus::prelude::*;
+/// # use dioxus_fullstack_hooks::*;
+/// # fn Children() -> Element { unimplemented!() }
+/// fn App() -> Element {
+///     // This will start streaming immediately after the current render is complete.
+///     use_hook(commit_initial_chunk);
+///
+///     rsx! { Children {} }
+/// }
+/// ```
+pub fn commit_initial_chunk() {
+    if let Some(mut streaming) = try_consume_context::<StreamingContext>() {
+        streaming.commit_initial_chunk();
+    }
+}
+
+/// Get the current status of the streaming response. This method is reactive and will cause
+/// the current reactive context to rerun when the status changes.
+///
+/// On the client, this will always return `StreamingStatus::InitialChunkCommitted`.
+///
+/// # Example
+/// ```rust, no_run
+/// # use dioxus::prelude::*;
+/// # use dioxus_fullstack_hooks::*;
+/// #[component]
+/// fn MetaTitle(title: String) -> Element {
+///     // If streaming has already started, warn the user that the meta tag will not show
+///     // up in the initial chunk.
+///     use_hook(|| {
+///         if current_status() == StreamingStatus::InitialChunkCommitted {
+///            log::warn!("Since `MetaTitle` was rendered after the initial chunk was committed, the meta tag will not show up in the head without javascript enabled.");
+///         }
+///     });
+///
+///     rsx! { meta { property: "og:title", content: title } }
+/// }
+/// ```
+pub fn current_status() -> StreamingStatus {
+    if let Some(streaming) = try_consume_context::<StreamingContext>() {
+        streaming.current_status()
+    } else {
+        StreamingStatus::InitialChunkCommitted
+    }
+}
diff --git a/packages/fullstack-protocol/Cargo.toml b/packages/fullstack-protocol/Cargo.toml
new file mode 100644
index 0000000000..60a8592410
--- /dev/null
+++ b/packages/fullstack-protocol/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "dioxus-fullstack-protocol"
+edition = "2021"
+version.workspace = true
+
+[dependencies]
+ciborium.workspace = true
+dioxus-core = { workspace = true }
+base64 = { workspace = true }
+serde = { workspace = true }
+tracing.workspace = true
+
+[features]
+web = []
\ No newline at end of file
diff --git a/packages/fullstack-protocol/README.md b/packages/fullstack-protocol/README.md
new file mode 100644
index 0000000000..cd0f68c0cc
--- /dev/null
+++ b/packages/fullstack-protocol/README.md
@@ -0,0 +1,3 @@
+# Fullstack Protocol
+
+Dioxus-fullstack-protocol is the internal protocol the dioxus web and server renderers use to communicate with each other in dioxus fullstack. It is used to send futures and values from the server to the client during fullstack rendering.
diff --git a/packages/fullstack-protocol/src/lib.rs b/packages/fullstack-protocol/src/lib.rs
new file mode 100644
index 0000000000..eb9e0f332f
--- /dev/null
+++ b/packages/fullstack-protocol/src/lib.rs
@@ -0,0 +1,401 @@
+#![warn(missing_docs)]
+#![doc = include_str!("../README.md")]
+
+use base64::Engine;
+use dioxus_core::CapturedError;
+use serde::Serialize;
+use std::{cell::RefCell, io::Cursor, rc::Rc};
+
+#[cfg(feature = "web")]
+thread_local! {
+    static CONTEXT: RefCell<Option<HydrationContext>> = const { RefCell::new(None) };
+}
+
+/// Data shared between the frontend and the backend for hydration
+/// of server functions.
+#[derive(Default, Clone)]
+pub struct HydrationContext {
+    #[cfg(feature = "web")]
+    /// Is resolving suspense done on the client
+    suspense_finished: bool,
+    data: Rc<RefCell<HTMLData>>,
+}
+
+impl HydrationContext {
+    /// Create a new serialize context from the serialized data
+    pub fn from_serialized(
+        data: &[u8],
+        debug_types: Option<Vec<String>>,
+        debug_locations: Option<Vec<String>>,
+    ) -> Self {
+        Self {
+            #[cfg(feature = "web")]
+            suspense_finished: false,
+            data: Rc::new(RefCell::new(HTMLData::from_serialized(
+                data,
+                debug_types,
+                debug_locations,
+            ))),
+        }
+    }
+
+    /// Serialize the data in the context to be sent to the client
+    pub fn serialized(&self) -> SerializedHydrationData {
+        self.data.borrow().serialized()
+    }
+
+    /// Create a new entry in the data that will be sent to the client without inserting any data. Returns an id that can be used to insert data into the entry once it is ready.
+    pub fn create_entry<T>(&self) -> SerializeContextEntry<T> {
+        let entry_index = self.data.borrow_mut().create_entry();
+
+        SerializeContextEntry {
+            index: entry_index,
+            context: self.clone(),
+            phantom: std::marker::PhantomData,
+        }
+    }
+
+    /// Get the entry for the error in the suspense boundary
+    pub fn error_entry(&self) -> SerializeContextEntry<Option<CapturedError>> {
+        // The first entry is reserved for the error
+        let entry_index = self.data.borrow_mut().create_entry_with_id(0);
+
+        SerializeContextEntry {
+            index: entry_index,
+            context: self.clone(),
+            phantom: std::marker::PhantomData,
+        }
+    }
+
+    /// Extend this data with the data from another [`HydrationContext`]
+    pub fn extend(&self, other: &Self) {
+        self.data.borrow_mut().extend(&other.data.borrow());
+    }
+
+    #[cfg(feature = "web")]
+    /// Run a closure inside of this context
+    pub fn in_context<T>(&self, f: impl FnOnce() -> T) -> T {
+        CONTEXT.with(|context| {
+            let old = context.borrow().clone();
+            *context.borrow_mut() = Some(self.clone());
+            let result = f();
+            *context.borrow_mut() = old;
+            result
+        })
+    }
+
+    pub(crate) fn insert<T: Serialize>(
+        &self,
+        id: usize,
+        value: &T,
+        location: &'static std::panic::Location<'static>,
+    ) {
+        self.data.borrow_mut().insert(id, value, location);
+    }
+
+    pub(crate) fn get<T: serde::de::DeserializeOwned>(
+        &self,
+        id: usize,
+    ) -> Result<T, TakeDataError> {
+        // If suspense is finished on the client, we can assume that the data is available
+        #[cfg(feature = "web")]
+        if self.suspense_finished {
+            return Err(TakeDataError::DataNotAvailable);
+        }
+        self.data.borrow().get(id)
+    }
+}
+
+/// An entry into the serialized context. The order entries are created in must be consistent
+/// between the server and the client.
+pub struct SerializeContextEntry<T> {
+    /// The index this context will be inserted into inside the serialize context
+    index: usize,
+    /// The context this entry is associated with
+    context: HydrationContext,
+    phantom: std::marker::PhantomData<T>,
+}
+
+impl<T> Clone for SerializeContextEntry<T> {
+    fn clone(&self) -> Self {
+        Self {
+            index: self.index,
+            context: self.context.clone(),
+            phantom: std::marker::PhantomData,
+        }
+    }
+}
+
+impl<T> SerializeContextEntry<T> {
+    /// Insert data into an entry that was created with [`SerializeContext::create_entry`]
+    pub fn insert(self, value: &T, location: &'static std::panic::Location<'static>)
+    where
+        T: Serialize,
+    {
+        self.context.insert(self.index, value, location);
+    }
+
+    /// Grab the data from the serialize context
+    pub fn get(&self) -> Result<T, TakeDataError>
+    where
+        T: serde::de::DeserializeOwned,
+    {
+        self.context.get(self.index)
+    }
+}
+
+/// Get or insert the current serialize context. On the client, the hydration context this returns
+/// will always return `TakeDataError::DataNotAvailable` if hydration of the current chunk is finished.
+pub fn serialize_context() -> HydrationContext {
+    #[cfg(feature = "web")]
+    // On the client, the hydration logic provides the context in a global
+    if let Some(current_context) = CONTEXT.with(|context| context.borrow().clone()) {
+        current_context
+    } else {
+        // If the context is not set, then suspense is not active
+        HydrationContext {
+            suspense_finished: true,
+            ..Default::default()
+        }
+    }
+    #[cfg(not(feature = "web"))]
+    {
+        // On the server each scope creates the context lazily
+        dioxus_core::prelude::has_context()
+            .unwrap_or_else(|| dioxus_core::prelude::provide_context(HydrationContext::default()))
+    }
+}
+
+pub(crate) struct HTMLData {
+    /// The position of the cursor in the data. This is only used on the client
+    pub(crate) cursor: usize,
+    /// The data required for hydration
+    pub data: Vec<Option<Vec<u8>>>,
+    /// The types of each serialized data
+    ///
+    /// NOTE: we don't store this in the main data vec because we don't want to include it in
+    /// release mode and we can't assume both the client and server are built with debug assertions
+    /// matching
+    #[cfg(debug_assertions)]
+    pub debug_types: Vec<Option<String>>,
+    /// The locations of each serialized data
+    #[cfg(debug_assertions)]
+    pub debug_locations: Vec<Option<String>>,
+}
+
+impl Default for HTMLData {
+    fn default() -> Self {
+        Self {
+            cursor: 1,
+            data: Vec::new(),
+            #[cfg(debug_assertions)]
+            debug_types: Vec::new(),
+            #[cfg(debug_assertions)]
+            debug_locations: Vec::new(),
+        }
+    }
+}
+
+impl HTMLData {
+    fn from_serialized(
+        data: &[u8],
+        debug_types: Option<Vec<String>>,
+        debug_locations: Option<Vec<String>>,
+    ) -> Self {
+        let data = ciborium::from_reader(Cursor::new(data)).unwrap();
+        Self {
+            cursor: 1,
+            data,
+            #[cfg(debug_assertions)]
+            debug_types: debug_types
+                .unwrap_or_default()
+                .into_iter()
+                .map(Some)
+                .collect(),
+            #[cfg(debug_assertions)]
+            debug_locations: debug_locations
+                .unwrap_or_default()
+                .into_iter()
+                .map(Some)
+                .collect(),
+        }
+    }
+
+    /// Create a new entry in the data that will be sent to the client without inserting any data. Returns an id that can be used to insert data into the entry once it is ready.
+    fn create_entry(&mut self) -> usize {
+        let id = self.cursor;
+        self.cursor += 1;
+        self.create_entry_with_id(id)
+    }
+
+    fn create_entry_with_id(&mut self, id: usize) -> usize {
+        while id + 1 > self.data.len() {
+            self.data.push(None);
+            #[cfg(debug_assertions)]
+            {
+                self.debug_types.push(None);
+                self.debug_locations.push(None);
+            }
+        }
+        id
+    }
+
+    /// Insert data into an entry that was created with [`Self::create_entry`]
+    fn insert<T: Serialize>(
+        &mut self,
+        id: usize,
+        value: &T,
+        location: &'static std::panic::Location<'static>,
+    ) {
+        let mut serialized = Vec::new();
+        ciborium::into_writer(value, &mut serialized).unwrap();
+        self.data[id] = Some(serialized);
+        #[cfg(debug_assertions)]
+        {
+            self.debug_types[id] = Some(std::any::type_name::<T>().to_string());
+            self.debug_locations[id] = Some(location.to_string());
+        }
+    }
+
+    /// Get the data from the serialize context
+    fn get<T: serde::de::DeserializeOwned>(&self, index: usize) -> Result<T, TakeDataError> {
+        if index >= self.data.len() {
+            tracing::trace!(
+                "Tried to take more data than was available, len: {}, index: {}; This is normal if the server function was started on the client, but may indicate a bug if the server function result should be deserialized from the server",
+                self.data.len(),
+                index
+            );
+            return Err(TakeDataError::DataNotAvailable);
+        }
+        let bytes = self.data[index].as_ref();
+        match bytes {
+            Some(bytes) => match ciborium::from_reader(Cursor::new(bytes)) {
+                Ok(x) => Ok(x),
+                Err(err) => {
+                    #[cfg(debug_assertions)]
+                    {
+                        let debug_type = self.debug_types.get(index);
+                        let debug_locations = self.debug_locations.get(index);
+
+                        if let (Some(Some(debug_type)), Some(Some(debug_locations))) =
+                            (debug_type, debug_locations)
+                        {
+                            let client_type = std::any::type_name::<T>();
+                            let client_location = std::panic::Location::caller();
+                            // We we have debug types and a location, we can provide a more helpful error message
+                            tracing::error!(
+                                "Error deserializing data: {err:?}\n\nThis type was serialized on the server at {debug_locations} with the type name {debug_type}. The client failed to deserialize the type {client_type} at {client_location}.",
+                            );
+                            return Err(TakeDataError::DeserializationError(err));
+                        }
+                    }
+                    // Otherwise, just log the generic deserialization error
+                    tracing::error!("Error deserializing data: {:?}", err);
+                    Err(TakeDataError::DeserializationError(err))
+                }
+            },
+            None => Err(TakeDataError::DataPending),
+        }
+    }
+
+    /// Extend this data with the data from another [`HTMLData`]
+    pub(crate) fn extend(&mut self, other: &Self) {
+        // Make sure this vectors error entry exists even if it is empty
+        if self.data.is_empty() {
+            self.data.push(None);
+            #[cfg(debug_assertions)]
+            {
+                self.debug_types.push(None);
+                self.debug_locations.push(None);
+            }
+        }
+
+        let mut other_data_iter = other.data.iter().cloned();
+        #[cfg(debug_assertions)]
+        let mut other_debug_types_iter = other.debug_types.iter().cloned();
+        #[cfg(debug_assertions)]
+        let mut other_debug_locations_iter = other.debug_locations.iter().cloned();
+
+        // Merge the error entry from the other context
+        if let Some(Some(other_error)) = other_data_iter.next() {
+            self.data[0] = Some(other_error.clone());
+            #[cfg(debug_assertions)]
+            {
+                self.debug_types[0] = other_debug_types_iter.next().unwrap_or(None);
+                self.debug_locations[0] = other_debug_locations_iter.next().unwrap_or(None);
+            }
+        }
+
+        // Don't copy the error from the other context
+        self.data.extend(other_data_iter);
+        #[cfg(debug_assertions)]
+        {
+            self.debug_types.extend(other_debug_types_iter);
+            self.debug_locations.extend(other_debug_locations_iter);
+        }
+    }
+
+    /// Encode data as base64. This is intended to be used in the server to send data to the client.
+    pub(crate) fn serialized(&self) -> SerializedHydrationData {
+        let mut serialized = Vec::new();
+        ciborium::into_writer(&self.data, &mut serialized).unwrap();
+
+        let data = base64::engine::general_purpose::STANDARD.encode(serialized);
+
+        let format_js_list_of_strings = |list: &[Option<String>]| {
+            let body = list
+                .iter()
+                .map(|s| match s {
+                    Some(s) => format!(r#""{s}""#),
+                    None => r#""unknown""#.to_string(),
+                })
+                .collect::<Vec<_>>()
+                .join(",");
+            format!("[{}]", body)
+        };
+
+        SerializedHydrationData {
+            data,
+            #[cfg(debug_assertions)]
+            debug_types: format_js_list_of_strings(&self.debug_types),
+            #[cfg(debug_assertions)]
+            debug_locations: format_js_list_of_strings(&self.debug_locations),
+        }
+    }
+}
+
+/// Data that was serialized on the server for hydration on the client. This includes
+/// extra information about the types and sources of the serialized data in debug mode
+pub struct SerializedHydrationData {
+    /// The base64 encoded serialized data
+    pub data: String,
+    /// A list of the types of each serialized data
+    #[cfg(debug_assertions)]
+    pub debug_types: String,
+    /// A list of the locations of each serialized data
+    #[cfg(debug_assertions)]
+    pub debug_locations: String,
+}
+
+/// An error that can occur when trying to take data from the server
+#[derive(Debug)]
+pub enum TakeDataError {
+    /// Deserializing the data failed
+    DeserializationError(ciborium::de::Error<std::io::Error>),
+    /// No data was available
+    DataNotAvailable,
+    /// The server serialized a placeholder for the data, but it isn't available yet
+    DataPending,
+}
+
+impl std::fmt::Display for TakeDataError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::DeserializationError(e) => write!(f, "DeserializationError: {}", e),
+            Self::DataNotAvailable => write!(f, "DataNotAvailable"),
+            Self::DataPending => write!(f, "DataPending"),
+        }
+    }
+}
+
+impl std::error::Error for TakeDataError {}
diff --git a/packages/fullstack/Cargo.toml b/packages/fullstack/Cargo.toml
index 803757c684..7a98434b62 100644
--- a/packages/fullstack/Cargo.toml
+++ b/packages/fullstack/Cargo.toml
@@ -25,7 +25,9 @@ generational-box = { workspace = true }
 # Dioxus + SSR
 dioxus-ssr = { workspace = true, optional = true }
 dioxus-isrg = { workspace = true, optional = true }
-dioxus-router = { workspace = true, optional = true }
+dioxus-router = { workspace = true, features = ["streaming"], optional = true }
+dioxus-fullstack-hooks = { workspace = true }
+dioxus-fullstack-protocol = { workspace = true }
 hyper = { workspace = true, optional = true }
 http = { workspace = true, optional = true }
 
@@ -83,7 +85,7 @@ devtools = ["dioxus-web?/devtools", "dep:dioxus-devtools"]
 mounted = ["dioxus-web?/mounted"]
 file_engine = ["dioxus-web?/file_engine"]
 document = ["dioxus-web?/document"]
-web = ["dep:dioxus-web", "dep:web-sys"]
+web = ["dep:dioxus-web", "dep:web-sys", "dioxus-fullstack-hooks/web"]
 desktop = ["dep:dioxus-desktop", "server_fn/reqwest", "dioxus_server_macro/reqwest"]
 mobile = ["dep:dioxus-mobile", "server_fn/reqwest", "dioxus_server_macro/reqwest"]
 default-tls = ["server_fn/default-tls"]
@@ -92,6 +94,7 @@ axum = ["dep:axum", "dep:tower-http", "server", "server_fn/axum", "dioxus_server
 server = [
     "server_fn/ssr",
     "dioxus_server_macro/server",
+    "dioxus-fullstack-hooks/server",
     "dep:tokio",
     "dep:tokio-util",
     "dep:tokio-stream",
diff --git a/packages/fullstack/src/document/mod.rs b/packages/fullstack/src/document/mod.rs
index 173b58b97a..153cb0d85d 100644
--- a/packages/fullstack/src/document/mod.rs
+++ b/packages/fullstack/src/document/mod.rs
@@ -2,7 +2,13 @@
 
 #[cfg(feature = "server")]
 pub mod server;
+use dioxus_fullstack_protocol::SerializeContextEntry;
 #[cfg(feature = "server")]
 pub use server::ServerDocument;
 #[cfg(all(feature = "web", feature = "document"))]
 pub mod web;
+
+#[allow(unused)]
+pub(crate) fn head_element_hydration_entry() -> SerializeContextEntry<bool> {
+    dioxus_fullstack_protocol::serialize_context().create_entry()
+}
diff --git a/packages/fullstack/src/document/server.rs b/packages/fullstack/src/document/server.rs
index 067dc1889a..ab7afa2bb1 100644
--- a/packages/fullstack/src/document/server.rs
+++ b/packages/fullstack/src/document/server.rs
@@ -64,8 +64,8 @@ impl ServerDocument {
         // We only serialize the head elements if the web document feature is enabled
         #[cfg(feature = "document")]
         {
-            let serialize = crate::html_storage::serialize_context();
-            serialize.push(&!self.0.borrow().streaming, std::panic::Location::caller());
+            super::head_element_hydration_entry()
+                .insert(&!self.0.borrow().streaming, std::panic::Location::caller());
         }
     }
 }
diff --git a/packages/fullstack/src/document/web.rs b/packages/fullstack/src/document/web.rs
index 27e9d4ed64..c57cb17eec 100644
--- a/packages/fullstack/src/document/web.rs
+++ b/packages/fullstack/src/document/web.rs
@@ -4,10 +4,12 @@
 use dioxus_lib::{document::*, prelude::queue_effect};
 use dioxus_web::WebDocument;
 
+use super::head_element_hydration_entry;
+
 fn head_element_written_on_server() -> bool {
-    dioxus_web::take_server_data()
+    head_element_hydration_entry()
+        .get()
         .ok()
-        .flatten()
         .unwrap_or_default()
 }
 
diff --git a/packages/fullstack/src/hooks/mod.rs b/packages/fullstack/src/hooks/mod.rs
deleted file mode 100644
index f42ce3f788..0000000000
--- a/packages/fullstack/src/hooks/mod.rs
+++ /dev/null
@@ -1,2 +0,0 @@
-pub mod server_cached;
-pub mod server_future;
diff --git a/packages/fullstack/src/html_storage/mod.rs b/packages/fullstack/src/html_storage/mod.rs
deleted file mode 100644
index c3c0bc84ed..0000000000
--- a/packages/fullstack/src/html_storage/mod.rs
+++ /dev/null
@@ -1,117 +0,0 @@
-#![cfg(feature = "server")]
-
-use dioxus_lib::prelude::{has_context, provide_context, use_hook};
-use serde::Serialize;
-use std::{cell::RefCell, rc::Rc};
-
-pub(crate) mod serialize;
-
-#[derive(Default, Clone)]
-pub(crate) struct SerializeContext {
-    data: Rc<RefCell<HTMLData>>,
-}
-
-impl SerializeContext {
-    /// Create a new entry in the data that will be sent to the client without inserting any data. Returns an id that can be used to insert data into the entry once it is ready.
-    pub(crate) fn create_entry(&self) -> usize {
-        self.data.borrow_mut().create_entry()
-    }
-
-    /// Insert data into an entry that was created with [`Self::create_entry`]
-    pub(crate) fn insert<T: Serialize>(
-        &self,
-        id: usize,
-        value: &T,
-        location: &'static std::panic::Location<'static>,
-    ) {
-        self.data.borrow_mut().insert(id, value, location);
-    }
-
-    /// Push resolved data into the serialized server data
-    pub(crate) fn push<T: Serialize>(
-        &self,
-        data: &T,
-        location: &'static std::panic::Location<'static>,
-    ) {
-        self.data.borrow_mut().push(data, location);
-    }
-}
-
-pub(crate) fn use_serialize_context() -> SerializeContext {
-    use_hook(serialize_context)
-}
-
-pub(crate) fn serialize_context() -> SerializeContext {
-    has_context().unwrap_or_else(|| provide_context(SerializeContext::default()))
-}
-
-#[derive(Default)]
-pub(crate) struct HTMLData {
-    /// The data required for hydration
-    pub data: Vec<Option<Vec<u8>>>,
-    /// The types of each serialized data
-    ///
-    /// NOTE: we don't store this in the main data vec because we don't want to include it in
-    /// release mode and we can't assume both the client and server are built with debug assertions
-    /// matching
-    #[cfg(debug_assertions)]
-    pub debug_types: Vec<Option<String>>,
-    /// The locations of each serialized data
-    #[cfg(debug_assertions)]
-    pub debug_locations: Vec<Option<String>>,
-}
-
-impl HTMLData {
-    /// Create a new entry in the data that will be sent to the client without inserting any data. Returns an id that can be used to insert data into the entry once it is ready.
-    fn create_entry(&mut self) -> usize {
-        let id = self.data.len();
-        self.data.push(None);
-        #[cfg(debug_assertions)]
-        {
-            self.debug_types.push(None);
-            self.debug_locations.push(None);
-        }
-        id
-    }
-
-    /// Insert data into an entry that was created with [`Self::create_entry`]
-    fn insert<T: Serialize>(
-        &mut self,
-        id: usize,
-        value: &T,
-        location: &'static std::panic::Location<'static>,
-    ) {
-        let mut serialized = Vec::new();
-        ciborium::into_writer(value, &mut serialized).unwrap();
-        self.data[id] = Some(serialized);
-        #[cfg(debug_assertions)]
-        {
-            self.debug_types[id] = Some(std::any::type_name::<T>().to_string());
-            self.debug_locations[id] = Some(location.to_string());
-        }
-    }
-
-    /// Push resolved data into the serialized server data
-    fn push<T: Serialize>(&mut self, data: &T, location: &'static std::panic::Location<'static>) {
-        let mut serialized = Vec::new();
-        ciborium::into_writer(data, &mut serialized).unwrap();
-        self.data.push(Some(serialized));
-        #[cfg(debug_assertions)]
-        {
-            self.debug_types
-                .push(Some(std::any::type_name::<T>().to_string()));
-            self.debug_locations.push(Some(location.to_string()));
-        }
-    }
-
-    /// Extend this data with the data from another [`HTMLData`]
-    pub(crate) fn extend(&mut self, other: &Self) {
-        self.data.extend_from_slice(&other.data);
-        #[cfg(debug_assertions)]
-        {
-            self.debug_types.extend_from_slice(&other.debug_types);
-            self.debug_locations
-                .extend_from_slice(&other.debug_locations);
-        }
-    }
-}
diff --git a/packages/fullstack/src/html_storage/serialize.rs b/packages/fullstack/src/html_storage/serialize.rs
deleted file mode 100644
index a96dd0c799..0000000000
--- a/packages/fullstack/src/html_storage/serialize.rs
+++ /dev/null
@@ -1,116 +0,0 @@
-use base64::Engine;
-use dioxus_lib::prelude::dioxus_core::DynamicNode;
-use dioxus_lib::prelude::{has_context, ErrorContext, ScopeId, SuspenseContext, VNode, VirtualDom};
-
-use super::SerializeContext;
-
-impl super::HTMLData {
-    /// Walks through the suspense boundary in a depth first order and extracts the data from the context API.
-    /// We use depth first order instead of relying on the order the hooks are called in because during suspense on the server, the order that futures are run in may be non deterministic.
-    pub(crate) fn extract_from_suspense_boundary(vdom: &VirtualDom, scope: ScopeId) -> Self {
-        let mut data = Self::default();
-        data.serialize_errors(vdom, scope);
-        data.take_from_scope(vdom, scope);
-        data
-    }
-
-    /// Get the errors from the suspense boundary
-    fn serialize_errors(&mut self, vdom: &VirtualDom, scope: ScopeId) {
-        // If there is an error boundary on the suspense boundary, grab the error from the context API
-        // and throw it on the client so that it bubbles up to the nearest error boundary
-        let error = vdom.in_runtime(|| {
-            scope
-                .consume_context::<ErrorContext>()
-                .and_then(|error_context| error_context.errors().first().cloned())
-        });
-        self.push(&error, std::panic::Location::caller());
-    }
-
-    fn take_from_scope(&mut self, vdom: &VirtualDom, scope: ScopeId) {
-        vdom.in_runtime(|| {
-            scope.in_runtime(|| {
-                // Grab any serializable server context from this scope
-                let context: Option<SerializeContext> = has_context();
-                if let Some(context) = context {
-                    self.extend(&context.data.borrow());
-                }
-            });
-        });
-
-        // then continue to any children
-        if let Some(scope) = vdom.get_scope(scope) {
-            // If this is a suspense boundary, move into the children first (even if they are suspended) because that will be run first on the client
-            if let Some(suspense_boundary) =
-                SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope.id())
-            {
-                if let Some(node) = suspense_boundary.suspended_nodes() {
-                    self.take_from_vnode(vdom, &node);
-                }
-            }
-            if let Some(node) = scope.try_root_node() {
-                self.take_from_vnode(vdom, node);
-            }
-        }
-    }
-
-    fn take_from_vnode(&mut self, vdom: &VirtualDom, vnode: &VNode) {
-        for (dynamic_node_index, dyn_node) in vnode.dynamic_nodes.iter().enumerate() {
-            match dyn_node {
-                DynamicNode::Component(comp) => {
-                    if let Some(scope) = comp.mounted_scope(dynamic_node_index, vnode, vdom) {
-                        self.take_from_scope(vdom, scope.id());
-                    }
-                }
-                DynamicNode::Fragment(nodes) => {
-                    for node in nodes {
-                        self.take_from_vnode(vdom, node);
-                    }
-                }
-                _ => {}
-            }
-        }
-    }
-
-    #[cfg(feature = "server")]
-    /// Encode data as base64. This is intended to be used in the server to send data to the client.
-    pub(crate) fn serialized(&self) -> SerializedHydrationData {
-        let mut serialized = Vec::new();
-        ciborium::into_writer(&self.data, &mut serialized).unwrap();
-
-        let data = base64::engine::general_purpose::STANDARD.encode(serialized);
-
-        let format_js_list_of_strings = |list: &[Option<String>]| {
-            let body = list
-                .iter()
-                .map(|s| match s {
-                    Some(s) => format!(r#""{s}""#),
-                    None => r#""unknown""#.to_string(),
-                })
-                .collect::<Vec<_>>()
-                .join(",");
-            format!("[{}]", body)
-        };
-
-        SerializedHydrationData {
-            data,
-            #[cfg(debug_assertions)]
-            debug_types: format_js_list_of_strings(&self.debug_types),
-            #[cfg(debug_assertions)]
-            debug_locations: format_js_list_of_strings(&self.debug_locations),
-        }
-    }
-}
-
-#[cfg(feature = "server")]
-/// Data that was serialized on the server for hydration on the client. This includes
-/// extra information about the types and sources of the serialized data in debug mode
-pub(crate) struct SerializedHydrationData {
-    /// The base64 encoded serialized data
-    pub data: String,
-    /// A list of the types of each serialized data
-    #[cfg(debug_assertions)]
-    pub debug_types: String,
-    /// A list of the locations of each serialized data
-    #[cfg(debug_assertions)]
-    pub debug_locations: String,
-}
diff --git a/packages/fullstack/src/lib.rs b/packages/fullstack/src/lib.rs
index b7cf7dc43a..c73c31a9af 100644
--- a/packages/fullstack/src/lib.rs
+++ b/packages/fullstack/src/lib.rs
@@ -6,14 +6,10 @@
 
 pub use once_cell;
 
-mod html_storage;
-
 #[cfg(feature = "axum")]
 #[cfg_attr(docsrs, doc(cfg(feature = "axum")))]
 pub mod server;
 
-mod hooks;
-
 pub mod document;
 #[cfg(feature = "server")]
 mod render;
@@ -30,8 +26,7 @@ mod server_context;
 
 /// A prelude of commonly used items in dioxus-fullstack.
 pub mod prelude {
-    use crate::hooks;
-    pub use hooks::{server_cached::use_server_cached, server_future::use_server_future};
+    pub use dioxus_fullstack_hooks::*;
 
     #[cfg(feature = "axum")]
     #[cfg_attr(docsrs, doc(cfg(feature = "axum")))]
diff --git a/packages/fullstack/src/render.rs b/packages/fullstack/src/render.rs
index 9586868d06..564eb2ea33 100644
--- a/packages/fullstack/src/render.rs
+++ b/packages/fullstack/src/render.rs
@@ -1,11 +1,13 @@
 //! A shared pool of renderers for efficient server side rendering.
 use crate::document::ServerDocument;
-use crate::html_storage::serialize::SerializedHydrationData;
 use crate::streaming::{Mount, StreamingRenderer};
 use dioxus_cli_config::base_path;
+use dioxus_fullstack_hooks::{StreamingContext, StreamingStatus};
+use dioxus_fullstack_protocol::{HydrationContext, SerializedHydrationData};
 use dioxus_interpreter_js::INITIALIZE_STREAMING_JS;
 use dioxus_isrg::{CachedRender, IncrementalRendererError, RenderFreshness};
 use dioxus_lib::document::Document;
+use dioxus_lib::prelude::dioxus_core::DynamicNode;
 use dioxus_router::prelude::ParseRouteError;
 use dioxus_ssr::Renderer;
 use futures_channel::mpsc::Sender;
@@ -51,6 +53,10 @@ where
     }
 }
 
+fn in_root_scope<T>(virtual_dom: &VirtualDom, f: impl FnOnce() -> T) -> T {
+    virtual_dom.in_runtime(|| ScopeId::ROOT.in_runtime(f))
+}
+
 /// Errors that can occur during server side rendering before the initial chunk is sent down
 pub enum SSRError {
     /// An error from the incremental renderer. This should result in a 500 code
@@ -180,7 +186,7 @@ impl SsrRendererPool {
         let myself = self.clone();
         let streaming_mode = cfg.streaming_mode;
 
-        let join_handle = spawn_platform(move || async move {
+        let create_render_future = move || async move {
             let mut virtual_dom = virtual_dom_factory();
             let document = std::rc::Rc::new(crate::document::server::ServerDocument::default());
             virtual_dom.provide_root_context(document.clone());
@@ -196,27 +202,47 @@ impl SsrRendererPool {
             } else {
                 history = dioxus_history::MemoryHistory::with_initial_path(&route);
             }
+            let streaming_context = in_root_scope(&virtual_dom, StreamingContext::new);
             virtual_dom.provide_root_context(Rc::new(history) as Rc<dyn dioxus_history::History>);
             virtual_dom.provide_root_context(document.clone() as std::rc::Rc<dyn Document>);
+            virtual_dom.provide_root_context(streaming_context);
 
-            // rebuild the virtual dom, which may call server_context()
-            with_server_context(server_context.clone(), || virtual_dom.rebuild_in_place());
+            // rebuild the virtual dom
+            virtual_dom.rebuild_in_place();
 
             // If streaming is disabled, wait for the virtual dom to finish all suspense work
             // before rendering anything
             if streaming_mode == StreamingMode::Disabled {
-                ProvideServerContext::new(virtual_dom.wait_for_suspense(), server_context.clone())
-                    .await
+                virtual_dom.wait_for_suspense().await;
             }
+            // Otherwise, just wait for the streaming context to signal the initial chunk is ready
+            else {
+                loop {
+                    // Check if the router has finished and set the streaming context to finished
+                    let streaming_context_finished =
+                        in_root_scope(&virtual_dom, || streaming_context.current_status())
+                            == StreamingStatus::InitialChunkCommitted;
+                    // Or if this app isn't using the router and has finished suspense
+                    let suspense_finished = !virtual_dom.suspended_tasks_remaining();
+                    if streaming_context_finished || suspense_finished {
+                        break;
+                    }
+
+                    // Wait for new async work that runs during suspense (mainly use_server_futures)
+                    virtual_dom.wait_for_suspense_work().await;
+
+                    // Do that async work
+                    virtual_dom.render_suspense_immediate().await;
+                }
+            }
+
             // check if there are any errors
-            let errors = with_server_context(server_context.clone(), || {
-                virtual_dom.in_runtime(|| {
-                    let error_context: ErrorContext = ScopeId::APP
-                        .consume_context()
-                        .expect("The root should be under an error boundary");
-                    let errors = error_context.errors();
-                    errors.to_vec()
-                })
+            let errors = virtual_dom.in_runtime(|| {
+                let error_context: ErrorContext = ScopeId::APP
+                    .consume_context()
+                    .expect("The root should be under an error boundary");
+                let errors = error_context.errors();
+                errors.to_vec()
             });
             if errors.is_empty() {
                 // If routing was successful, we can return a 200 status and render into the stream
@@ -282,16 +308,8 @@ impl SsrRendererPool {
 
             // After the initial render, we need to resolve suspense
             while virtual_dom.suspended_tasks_remaining() {
-                ProvideServerContext::new(
-                    virtual_dom.wait_for_suspense_work(),
-                    server_context.clone(),
-                )
-                .await;
-                let resolved_suspense_nodes = ProvideServerContext::new(
-                    virtual_dom.render_suspense_immediate(),
-                    server_context.clone(),
-                )
-                .await;
+                virtual_dom.wait_for_suspense_work().await;
+                let resolved_suspense_nodes = virtual_dom.render_suspense_immediate().await;
 
                 // Just rerender the resolved nodes
                 for scope in resolved_suspense_nodes {
@@ -369,6 +387,10 @@ impl SsrRendererPool {
 
             renderer.reset_render_components();
             myself.renderers.write().unwrap().push(renderer);
+        };
+
+        let join_handle = spawn_platform(move || {
+            ProvideServerContext::new(create_render_future(), server_context)
         });
 
         // Wait for the initial result which determines the status code
@@ -464,13 +486,83 @@ fn start_capturing_errors(suspense_scope: ScopeId) {
 fn serialize_server_data(virtual_dom: &VirtualDom, scope: ScopeId) -> SerializedHydrationData {
     // After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
     // Extract any data we serialized for hydration (from server futures)
-    let html_data =
-        crate::html_storage::HTMLData::extract_from_suspense_boundary(virtual_dom, scope);
+    let html_data = extract_from_suspense_boundary(virtual_dom, scope);
 
     // serialize the server state into a base64 string
     html_data.serialized()
 }
 
+/// Walks through the suspense boundary in a depth first order and extracts the data from the context API.
+/// We use depth first order instead of relying on the order the hooks are called in because during suspense on the server, the order that futures are run in may be non deterministic.
+pub(crate) fn extract_from_suspense_boundary(
+    vdom: &VirtualDom,
+    scope: ScopeId,
+) -> HydrationContext {
+    let data = HydrationContext::default();
+    serialize_errors(&data, vdom, scope);
+    take_from_scope(&data, vdom, scope);
+    data
+}
+
+/// Get the errors from the suspense boundary
+fn serialize_errors(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) {
+    // If there is an error boundary on the suspense boundary, grab the error from the context API
+    // and throw it on the client so that it bubbles up to the nearest error boundary
+    let error = vdom.in_runtime(|| {
+        scope
+            .consume_context::<ErrorContext>()
+            .and_then(|error_context| error_context.errors().first().cloned())
+    });
+    context
+        .error_entry()
+        .insert(&error, std::panic::Location::caller());
+}
+
+fn take_from_scope(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) {
+    vdom.in_runtime(|| {
+        scope.in_runtime(|| {
+            // Grab any serializable server context from this scope
+            let other: Option<HydrationContext> = has_context();
+            if let Some(other) = other {
+                context.extend(&other);
+            }
+        });
+    });
+
+    // then continue to any children
+    if let Some(scope) = vdom.get_scope(scope) {
+        // If this is a suspense boundary, move into the children first (even if they are suspended) because that will be run first on the client
+        if let Some(suspense_boundary) =
+            SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope.id())
+        {
+            if let Some(node) = suspense_boundary.suspended_nodes() {
+                take_from_vnode(context, vdom, &node);
+            }
+        }
+        if let Some(node) = scope.try_root_node() {
+            take_from_vnode(context, vdom, node);
+        }
+    }
+}
+
+fn take_from_vnode(context: &HydrationContext, vdom: &VirtualDom, vnode: &VNode) {
+    for (dynamic_node_index, dyn_node) in vnode.dynamic_nodes.iter().enumerate() {
+        match dyn_node {
+            DynamicNode::Component(comp) => {
+                if let Some(scope) = comp.mounted_scope(dynamic_node_index, vnode, vdom) {
+                    take_from_scope(context, vdom, scope.id());
+                }
+            }
+            DynamicNode::Fragment(nodes) => {
+                for node in nodes {
+                    take_from_vnode(context, vdom, node);
+                }
+            }
+            _ => {}
+        }
+    }
+}
+
 /// State used in server side rendering. This utilizes a pool of [`dioxus_ssr::Renderer`]s to cache static templates between renders.
 #[derive(Clone)]
 pub struct SSRState {
diff --git a/packages/fullstack/src/serve_config.rs b/packages/fullstack/src/serve_config.rs
index 46642eaca6..60df4123f9 100644
--- a/packages/fullstack/src/serve_config.rs
+++ b/packages/fullstack/src/serve_config.rs
@@ -343,7 +343,6 @@ impl ServeConfigBuilder {
 
         let index_path = self
             .index_path
-            .map(PathBuf::from)
             .unwrap_or_else(|| public_path.join("index.html"));
 
         let root_id = self.root_id.unwrap_or("main");
@@ -483,6 +482,8 @@ pub enum StreamingMode {
 pub struct ServeConfig {
     pub(crate) index: IndexHtml,
     pub(crate) incremental: Option<dioxus_isrg::IncrementalRendererConfig>,
+    // This is used in the axum integration
+    #[allow(unused)]
     pub(crate) context_providers: ContextProviders,
     pub(crate) streaming_mode: StreamingMode,
 }
diff --git a/packages/fullstack/src/server/mod.rs b/packages/fullstack/src/server/mod.rs
index fe725d45be..5ed8eb1df4 100644
--- a/packages/fullstack/src/server/mod.rs
+++ b/packages/fullstack/src/server/mod.rs
@@ -273,16 +273,6 @@ where
     }
 }
 
-fn apply_request_parts_to_response<B>(
-    headers: hyper::header::HeaderMap,
-    response: &mut axum::response::Response<B>,
-) {
-    let mut_headers = response.headers_mut();
-    for (key, value) in headers.iter() {
-        mut_headers.insert(key, value.clone());
-    }
-}
-
 fn add_server_context(server_context: &DioxusServerContext, context_providers: &ContextProviders) {
     for index in 0..context_providers.len() {
         let context_providers = context_providers.clone();
@@ -410,8 +400,7 @@ pub async fn render_handler(
         Ok((freshness, rx)) => {
             let mut response = axum::response::Html::from(Body::from_stream(rx)).into_response();
             freshness.write(response.headers_mut());
-            let headers = server_context.response_parts().headers.clone();
-            apply_request_parts_to_response(headers, &mut response);
+            server_context.send_response(&mut response);
             Result::<http::Response<axum::body::Body>, StatusCode>::Ok(response)
         }
         Err(SSRError::Incremental(e)) => {
@@ -484,8 +473,7 @@ async fn handle_server_fns_inner(
             }
 
             // apply the response parts from the server context to the response
-            let mut res_options = server_context.response_parts_mut();
-            res.headers_mut().extend(res_options.headers.drain());
+            server_context.send_response(&mut res);
 
             Ok(res)
         } else {
diff --git a/packages/fullstack/src/server_context.rs b/packages/fullstack/src/server_context.rs
index 1d4d5c4791..a160828e90 100644
--- a/packages/fullstack/src/server_context.rs
+++ b/packages/fullstack/src/server_context.rs
@@ -23,9 +23,10 @@ type SendSyncAnyMap = std::collections::HashMap<std::any::TypeId, ContextType>;
 /// ```
 #[derive(Clone)]
 pub struct DioxusServerContext {
-    shared_context: std::sync::Arc<RwLock<SendSyncAnyMap>>,
-    response_parts: std::sync::Arc<RwLock<http::response::Parts>>,
+    shared_context: Arc<RwLock<SendSyncAnyMap>>,
+    response_parts: Arc<RwLock<http::response::Parts>>,
     pub(crate) parts: Arc<RwLock<http::request::Parts>>,
+    response_sent: Arc<std::sync::atomic::AtomicBool>,
 }
 
 enum ContextType {
@@ -46,11 +47,12 @@ impl ContextType {
 impl Default for DioxusServerContext {
     fn default() -> Self {
         Self {
-            shared_context: std::sync::Arc::new(RwLock::new(HashMap::new())),
-            response_parts: std::sync::Arc::new(RwLock::new(
+            shared_context: Arc::new(RwLock::new(HashMap::new())),
+            response_parts: Arc::new(RwLock::new(
                 http::response::Response::new(()).into_parts().0,
             )),
-            parts: std::sync::Arc::new(RwLock::new(http::request::Request::new(()).into_parts().0)),
+            parts: Arc::new(RwLock::new(http::request::Request::new(()).into_parts().0)),
+            response_sent: Arc::new(std::sync::atomic::AtomicBool::new(false)),
         }
     }
 }
@@ -69,6 +71,7 @@ mod server_fn_impl {
                 response_parts: std::sync::Arc::new(RwLock::new(
                     http::response::Response::new(()).into_parts().0,
                 )),
+                response_sent: Arc::new(std::sync::atomic::AtomicBool::new(false)),
             }
         }
 
@@ -81,6 +84,7 @@ mod server_fn_impl {
                 response_parts: std::sync::Arc::new(RwLock::new(
                     http::response::Response::new(()).into_parts().0,
                 )),
+                response_sent: Arc::new(std::sync::atomic::AtomicBool::new(false)),
             }
         }
 
@@ -192,6 +196,14 @@ mod server_fn_impl {
         /// }
         /// ```
         pub fn response_parts_mut(&self) -> RwLockWriteGuard<'_, http::response::Parts> {
+            if self
+                .response_sent
+                .load(std::sync::atomic::Ordering::Relaxed)
+            {
+                tracing::error!("Attempted to modify the request after the first frame of the response has already been sent. \
+                You can read the response, but modifying the response will not change the response that the client has already received. \
+                Try modifying the response before the suspense boundary above the router is resolved.");
+            }
             self.response_parts.write()
         }
 
@@ -261,6 +273,22 @@ mod server_fn_impl {
         pub async fn extract<M, T: FromServerContext<M>>(&self) -> Result<T, T::Rejection> {
             T::from_request(self).await
         }
+
+        /// Copy the response parts to a response and mark this server context as sent
+        #[cfg(feature = "axum")]
+        pub(crate) fn send_response<B>(&self, response: &mut http::response::Response<B>) {
+            self.response_sent
+                .store(true, std::sync::atomic::Ordering::Relaxed);
+            let parts = self.response_parts.read();
+
+            let mut_headers = response.headers_mut();
+            for (key, value) in parts.headers.iter() {
+                mut_headers.insert(key, value.clone());
+            }
+            *response.status_mut() = parts.status;
+            *response.version_mut() = parts.version;
+            response.extensions_mut().extend(parts.extensions.clone());
+        }
     }
 }
 
diff --git a/packages/fullstack/src/streaming.rs b/packages/fullstack/src/streaming.rs
index b8133e2add..263fffe05d 100644
--- a/packages/fullstack/src/streaming.rs
+++ b/packages/fullstack/src/streaming.rs
@@ -26,6 +26,7 @@
 //! </script>
 //! ```
 
+use dioxus_fullstack_protocol::SerializedHydrationData;
 use futures_channel::mpsc::Sender;
 
 use std::{
@@ -33,8 +34,6 @@ use std::{
     sync::{Arc, RwLock},
 };
 
-use crate::html_storage::serialize::SerializedHydrationData;
-
 /// Sections are identified by a unique id based on the suspense path. We only track the path of suspense boundaries because the client may render different components than the server.
 #[derive(Clone, Debug, Default)]
 struct MountPath {
@@ -127,10 +126,10 @@ impl<E> StreamingRenderer<E> {
         // 2. The serialized data required to hydrate those components
         // 3. (in debug mode) The type names of the serialized data
         // 4. (in debug mode) The locations of the serialized data
-        let raw_data = resolved_data.data;
         write!(
             into,
-            r#"</div><script>window.dx_hydrate([{id}], "{raw_data}""#
+            r#"</div><script>window.dx_hydrate([{id}], "{}""#,
+            resolved_data.data
         )?;
         #[cfg(debug_assertions)]
         {
diff --git a/packages/hooks/src/lib.rs b/packages/hooks/src/lib.rs
index 2e2b28640f..be9c07b841 100644
--- a/packages/hooks/src/lib.rs
+++ b/packages/hooks/src/lib.rs
@@ -95,3 +95,6 @@ pub use use_signal::*;
 
 mod use_set_compare;
 pub use use_set_compare::*;
+
+mod use_after_suspense_resolved;
+pub use use_after_suspense_resolved::*;
diff --git a/packages/hooks/src/use_after_suspense_resolved.rs b/packages/hooks/src/use_after_suspense_resolved.rs
new file mode 100644
index 0000000000..1cbf2510b6
--- /dev/null
+++ b/packages/hooks/src/use_after_suspense_resolved.rs
@@ -0,0 +1,20 @@
+use dioxus_core::{prelude::suspense_context, use_hook};
+
+/// Run a closure after the suspense boundary this is under is resolved. The
+/// closure will be run immediately if the suspense boundary is already resolved
+/// or the scope is not under a suspense boundary.
+pub fn use_after_suspense_resolved(suspense_resolved: impl FnOnce() + 'static) {
+    use_hook(|| {
+        // If this is under a suspense boundary, we need to check if it is resolved
+        match suspense_context() {
+            Some(context) => {
+                // If it is suspended, run the closure after the suspense is resolved
+                context.after_suspense_resolved(suspense_resolved)
+            }
+            None => {
+                // Otherwise, just run the resolved closure immediately
+                suspense_resolved();
+            }
+        }
+    })
+}
diff --git a/packages/playwright-tests/fullstack/src/main.rs b/packages/playwright-tests/fullstack/src/main.rs
index c68f2b4326..46311d8b90 100644
--- a/packages/playwright-tests/fullstack/src/main.rs
+++ b/packages/playwright-tests/fullstack/src/main.rs
@@ -107,6 +107,9 @@ async fn server_error() -> Result<String, ServerFnError> {
 
 #[component]
 fn Errors() -> Element {
+    // Make the suspense boundary below happen during streaming
+    use_hook(commit_initial_chunk);
+
     rsx! {
         // This is a tricky case for suspense https://github.com/DioxusLabs/dioxus/issues/2570
         // Root suspense boundary is already resolved when the inner suspense boundary throws an error.
@@ -132,7 +135,7 @@ fn Errors() -> Element {
 pub fn ThrowsError() -> Element {
     use_server_future(server_error)?
         .unwrap()
-        .map_err(|err| RenderError::Aborted(CapturedError::from_display(err)))?;
+        .map_err(CapturedError::from_display)?;
     rsx! {
         "success"
     }
diff --git a/packages/playwright-tests/nested-suspense/src/lib.rs b/packages/playwright-tests/nested-suspense/src/lib.rs
index 1a9d472e23..7df137d47d 100644
--- a/packages/playwright-tests/nested-suspense/src/lib.rs
+++ b/packages/playwright-tests/nested-suspense/src/lib.rs
@@ -11,6 +11,9 @@ use dioxus::prelude::*;
 use serde::{Deserialize, Serialize};
 
 pub fn app() -> Element {
+    // Start streaming immediately
+    use_hook(commit_initial_chunk);
+
     rsx! {
         SuspenseBoundary {
             fallback: move |_| rsx! {},
diff --git a/packages/playwright-tests/suspense-carousel/src/main.rs b/packages/playwright-tests/suspense-carousel/src/main.rs
index 4a80b5292c..2122761dec 100644
--- a/packages/playwright-tests/suspense-carousel/src/main.rs
+++ b/packages/playwright-tests/suspense-carousel/src/main.rs
@@ -9,6 +9,8 @@ use dioxus::prelude::*;
 use serde::{Deserialize, Serialize};
 
 fn app() -> Element {
+    // Start streaming immediately
+    use_hook(commit_initial_chunk);
     let mut count = use_signal(|| 0);
 
     rsx! {
diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml
index a84921ffec..2594045a5b 100644
--- a/packages/router/Cargo.toml
+++ b/packages/router/Cargo.toml
@@ -13,6 +13,7 @@ keywords = ["dom", "ui", "gui", "react", "wasm"]
 dioxus-lib = { workspace = true }
 dioxus-history = { workspace = true }
 dioxus-router-macro = { workspace = true }
+dioxus-fullstack-hooks = { workspace = true, optional = true }
 tracing = { workspace = true }
 urlencoding = "2.1.3"
 url = "2.3.1"
@@ -21,6 +22,7 @@ rustversion = "1.0.17"
 
 [features]
 default = []
+streaming = ["dep:dioxus-fullstack-hooks"]
 wasm-split = []
 
 [dev-dependencies]
diff --git a/packages/router/src/components/router.rs b/packages/router/src/components/router.rs
index 0d5d668164..1ea2ee54a4 100644
--- a/packages/router/src/components/router.rs
+++ b/packages/router/src/components/router.rs
@@ -44,6 +44,11 @@ pub fn Router<R: Routable + Clone>(props: RouterProps<R>) -> Element {
         provide_router_context(RouterContext::new(props.config.call(())));
     });
 
+    #[cfg(feature = "streaming")]
+    use_after_suspense_resolved(|| {
+        dioxus_fullstack_hooks::commit_initial_chunk();
+    });
+
     use_hook(|| {
         provide_context(OutletContext::<R>::new());
     });
diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml
index 9c37dc5090..cbd91b0c14 100644
--- a/packages/web/Cargo.toml
+++ b/packages/web/Cargo.toml
@@ -22,6 +22,7 @@ dioxus-interpreter-js = { workspace = true, features = [
     "minimal_bindings",
     "webonly",
 ] }
+dioxus-fullstack-protocol = { workspace = true, features = ["web"], optional = true }
 generational-box = { workspace = true }
 
 js-sys = "0.3.70"
@@ -39,7 +40,6 @@ serde_json = { version = "1.0", optional = true }
 serde = { version = "1.0", optional = true }
 serde-wasm-bindgen = { version = "0.5.0", optional = true }
 
-ciborium = { workspace = true, optional = true }
 async-trait = { version = "0.1.58", optional = true }
 
 [dependencies.web-sys]
@@ -86,7 +86,7 @@ lazy-js-bundle = { workspace = true }
 
 [features]
 default = ["mounted", "file_engine", "devtools", "document"]
-hydrate = ["web-sys/Comment", "ciborium", "dep:serde"]
+hydrate = ["web-sys/Comment", "dep:serde", "dep:dioxus-fullstack-protocol"]
 mounted = [
     "web-sys/Element",
     "dioxus-html/mounted",
diff --git a/packages/web/src/hydration/deserialize.rs b/packages/web/src/hydration/deserialize.rs
deleted file mode 100644
index 9f9295c6c7..0000000000
--- a/packages/web/src/hydration/deserialize.rs
+++ /dev/null
@@ -1,161 +0,0 @@
-use std::cell::{Cell, RefCell};
-use std::io::Cursor;
-
-use dioxus_core::CapturedError;
-use serde::de::DeserializeOwned;
-
-thread_local! {
-    static SERVER_DATA: RefCell<Option<HTMLDataCursor>> = const { RefCell::new(None) };
-}
-
-/// Try to take the next item from the server data cursor. This will only be set during the first run of a component before hydration.
-/// This will return `None` if no data was pushed for this instance or if serialization fails
-// TODO: evan better docs
-#[track_caller]
-pub fn take_server_data<T: DeserializeOwned>() -> Result<Option<T>, TakeDataError> {
-    SERVER_DATA.with_borrow(|data| match data.as_ref() {
-        Some(data) => data.take(),
-        None => Err(TakeDataError::DataNotAvailable),
-    })
-}
-
-/// Run a closure with the server data
-pub(crate) fn with_server_data<O>(server_data: HTMLDataCursor, f: impl FnOnce() -> O) -> O {
-    // Set the server data that will be used during hydration
-    set_server_data(server_data);
-    let out = f();
-    // Hydrating the suspense node **should** eat all the server data, but just in case, remove it
-    remove_server_data();
-    out
-}
-
-fn set_server_data(data: HTMLDataCursor) {
-    SERVER_DATA.with_borrow_mut(|server_data| *server_data = Some(data));
-}
-
-fn remove_server_data() {
-    SERVER_DATA.with_borrow_mut(|server_data| server_data.take());
-}
-
-/// Data that is deserialized from the server during hydration
-pub(crate) struct HTMLDataCursor {
-    error: Option<CapturedError>,
-    data: Vec<Option<Vec<u8>>>,
-    #[cfg(debug_assertions)]
-    debug_types: Option<Vec<String>>,
-    #[cfg(debug_assertions)]
-    debug_locations: Option<Vec<String>>,
-    index: Cell<usize>,
-}
-
-impl HTMLDataCursor {
-    pub(crate) fn from_serialized(
-        data: &[u8],
-        debug_types: Option<Vec<String>>,
-        debug_locations: Option<Vec<String>>,
-    ) -> Self {
-        let deserialized = ciborium::from_reader(Cursor::new(data)).unwrap();
-        Self::new(deserialized, debug_types, debug_locations)
-    }
-
-    /// Get the error if there is one
-    pub(crate) fn error(&self) -> Option<CapturedError> {
-        self.error.clone()
-    }
-
-    fn new(
-        data: Vec<Option<Vec<u8>>>,
-        #[allow(unused)] debug_types: Option<Vec<String>>,
-        #[allow(unused)] debug_locations: Option<Vec<String>>,
-    ) -> Self {
-        let mut myself = Self {
-            index: Cell::new(0),
-            error: None,
-            data,
-            #[cfg(debug_assertions)]
-            debug_types,
-            #[cfg(debug_assertions)]
-            debug_locations,
-        };
-
-        // The first item is always an error if it exists
-        let error = myself
-            .take::<Option<CapturedError>>()
-            .ok()
-            .flatten()
-            .flatten();
-
-        myself.error = error;
-
-        myself
-    }
-
-    #[track_caller]
-    pub fn take<T: DeserializeOwned>(&self) -> Result<Option<T>, TakeDataError> {
-        let current = self.index.get();
-        if current >= self.data.len() {
-            tracing::trace!(
-                "Tried to take more data than was available, len: {}, index: {}; This is normal if the server function was started on the client, but may indicate a bug if the server function result should be deserialized from the server",
-                self.data.len(),
-                current
-            );
-            return Err(TakeDataError::DataNotAvailable);
-        }
-        let bytes = self.data[current].as_ref();
-        self.index.set(current + 1);
-        match bytes {
-            Some(bytes) => match ciborium::from_reader(Cursor::new(bytes)) {
-                Ok(x) => Ok(Some(x)),
-                Err(err) => {
-                    #[cfg(debug_assertions)]
-                    {
-                        let debug_type = self
-                            .debug_types
-                            .as_ref()
-                            .and_then(|types| types.get(current));
-                        let debug_locations = self
-                            .debug_locations
-                            .as_ref()
-                            .and_then(|locations| locations.get(current));
-
-                        if let (Some(debug_type), Some(debug_locations)) =
-                            (debug_type, debug_locations)
-                        {
-                            let client_type = std::any::type_name::<T>();
-                            let client_location = std::panic::Location::caller();
-                            // We we have debug types and a location, we can provide a more helpful error message
-                            tracing::error!(
-                                "Error deserializing data: {err:?}\n\nThis type was serialized on the server at {debug_locations} with the type name {debug_type}. The client failed to deserialize the type {client_type} at {client_location}.",
-                            );
-                            return Err(TakeDataError::DeserializationError(err));
-                        }
-                    }
-                    // Otherwise, just log the generic deserialization error
-                    tracing::error!("Error deserializing data: {:?}", err);
-                    Err(TakeDataError::DeserializationError(err))
-                }
-            },
-            None => Ok(None),
-        }
-    }
-}
-
-/// An error that can occur when trying to take data from the server
-#[derive(Debug)]
-pub enum TakeDataError {
-    /// Deserializing the data failed
-    DeserializationError(ciborium::de::Error<std::io::Error>),
-    /// No data was available
-    DataNotAvailable,
-}
-
-impl std::fmt::Display for TakeDataError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::DeserializationError(e) => write!(f, "DeserializationError: {}", e),
-            Self::DataNotAvailable => write!(f, "DataNotAvailable"),
-        }
-    }
-}
-
-impl std::error::Error for TakeDataError {}
diff --git a/packages/web/src/hydration/hydrate.rs b/packages/web/src/hydration/hydrate.rs
index 158500be98..4fef30a234 100644
--- a/packages/web/src/hydration/hydrate.rs
+++ b/packages/web/src/hydration/hydrate.rs
@@ -4,11 +4,10 @@
 //! 3. Register a callback for dx_hydrate(id, data) that takes some new data, reruns the suspense boundary with that new data and then rehydrates the node
 
 use crate::dom::WebsysDom;
-use crate::with_server_data;
-use crate::HTMLDataCursor;
 use dioxus_core::prelude::*;
 use dioxus_core::AttributeValue;
 use dioxus_core::{DynamicNode, ElementId};
+use dioxus_fullstack_protocol::HydrationContext;
 use futures_channel::mpsc::UnboundedReceiver;
 use std::fmt::Write;
 use RehydrationError::*;
@@ -146,12 +145,12 @@ impl WebsysDom {
         #[cfg(not(debug_assertions))]
         let debug_locations = None;
 
-        let server_data = HTMLDataCursor::from_serialized(&data, debug_types, debug_locations);
+        let server_data = HydrationContext::from_serialized(&data, debug_types, debug_locations);
         // If the server serialized an error into the suspense boundary, throw it on the client so that it bubbles up to the nearest error boundary
-        if let Some(error) = server_data.error() {
+        if let Some(error) = server_data.error_entry().get().ok().flatten() {
             dom.in_runtime(|| id.throw_error(error));
         }
-        with_server_data(server_data, || {
+        server_data.in_context(|| {
             // rerun the scope with the new data
             SuspenseBoundaryProps::resolve_suspense(
                 id,
diff --git a/packages/web/src/hydration/mod.rs b/packages/web/src/hydration/mod.rs
index 04fd398180..b149bf4fb0 100644
--- a/packages/web/src/hydration/mod.rs
+++ b/packages/web/src/hydration/mod.rs
@@ -1,10 +1,6 @@
 #[cfg(feature = "hydrate")]
-mod deserialize;
-#[cfg(feature = "hydrate")]
 mod hydrate;
 
-#[cfg(feature = "hydrate")]
-pub use deserialize::*;
 #[cfg(feature = "hydrate")]
 #[allow(unused)]
 pub use hydrate::*;
diff --git a/packages/web/src/lib.rs b/packages/web/src/lib.rs
index 3ad1290cde..8b2cd6a7fa 100644
--- a/packages/web/src/lib.rs
+++ b/packages/web/src/lib.rs
@@ -23,6 +23,7 @@
 pub use crate::cfg::Config;
 use crate::hydration::SuspenseMessage;
 use dioxus_core::VirtualDom;
+use dioxus_fullstack_protocol::HydrationContext;
 use dom::WebsysDom;
 use futures_util::{pin_mut, select, FutureExt, StreamExt};
 
@@ -113,12 +114,12 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! {
             let debug_locations = None;
 
             let server_data =
-                HTMLDataCursor::from_serialized(&hydration_data, debug_types, debug_locations);
+                HydrationContext::from_serialized(&hydration_data, debug_types, debug_locations);
             // If the server serialized an error into the root suspense boundary, throw it into the root scope
-            if let Some(error) = server_data.error() {
+            if let Some(error) = server_data.error_entry().get().ok().flatten() {
                 virtual_dom.in_runtime(|| dioxus_core::ScopeId::APP.throw_error(error));
             }
-            with_server_data(server_data, || {
+            server_data.in_context(|| {
                 virtual_dom.rebuild(&mut websys_dom);
             });
             websys_dom.skip_mutations = false;