diff --git a/.tci b/.tci
index 5cc5043..ed53713 100755
--- a/.tci
+++ b/.tci
@@ -12,7 +12,7 @@ echo "restarting service"
 systemctl --user start upub
 echo "rebuilding frontend"
 cd web
-CARGO_BUILD_JOBS=4 /opt/bin/trunk build --release --public-url 'https://dev.upub.social/web'
+CARGO_BUILD_JOBS=4 /opt/bin/trunk build --profile=wasm-release --public-url 'https://dev.upub.social/web'
 echo "deploying frontend"
 rm /srv/http/upub/dev/web/*
 mv ./dist/* /srv/http/upub/dev/web/
diff --git a/Cargo.lock b/Cargo.lock
index c8b4da0..c736f71 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -270,44 +270,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
 dependencies = [
  "async-trait",
- "axum-core 0.4.5",
+ "axum-core",
  "bytes",
  "futures-util",
  "http 1.2.0",
  "http-body",
  "http-body-util",
- "itoa",
- "matchit 0.7.3",
- "memchr",
- "mime",
- "multer",
- "percent-encoding",
- "pin-project-lite",
- "rustversion",
- "serde",
- "sync_wrapper",
- "tower",
- "tower-layer",
- "tower-service",
-]
-
-[[package]]
-name = "axum"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
-dependencies = [
- "axum-core 0.5.0",
- "bytes",
- "form_urlencoded",
- "futures-util",
- "http 1.2.0",
- "http-body",
- "http-body-util",
  "hyper",
  "hyper-util",
  "itoa",
- "matchit 0.8.4",
+ "matchit",
  "memchr",
  "mime",
  "multer",
@@ -344,25 +316,6 @@ dependencies = [
  "sync_wrapper",
  "tower-layer",
  "tower-service",
-]
-
-[[package]]
-name = "axum-core"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733"
-dependencies = [
- "bytes",
- "futures-util",
- "http 1.2.0",
- "http-body",
- "http-body-util",
- "mime",
- "pin-project-lite",
- "rustversion",
- "sync_wrapper",
- "tower-layer",
- "tower-service",
  "tracing",
 ]
 
@@ -1710,7 +1663,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 name = "httpsign"
 version = "0.1.0"
 dependencies = [
- "axum 0.8.1",
+ "axum",
  "base64",
  "openssl",
  "thiserror 2.0.11",
@@ -2137,7 +2090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "43b613d5784037baee42a11d21bc263adfc1a55e416556a3d5bfe39c7b87fadf"
 dependencies = [
  "any_spawner",
- "axum 0.7.9",
+ "axum",
  "dashmap",
  "futures",
  "hydration_context",
@@ -2452,12 +2405,6 @@ version = "0.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
 
-[[package]]
-name = "matchit"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
-
 [[package]]
 name = "md-5"
 version = "0.10.6"
@@ -3881,7 +3828,7 @@ version = "0.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f5dd7fcccd3ef2081da086c1f8595b506627abbbbc9f64be0141d2251219570e"
 dependencies = [
- "axum 0.7.9",
+ "axum",
  "bytes",
  "const_format",
  "dashmap",
@@ -5124,11 +5071,13 @@ dependencies = [
  "apb",
  "async-recursion",
  "async-trait",
+ "axum",
  "base64",
  "chrono",
  "hmac",
  "httpsign",
  "jrd",
+ "leptos_config",
  "mdhtml",
  "nodeinfo",
  "openssl",
@@ -5197,14 +5146,12 @@ name = "upub-routes"
 version = "0.3.0"
 dependencies = [
  "apb",
- "axum 0.8.1",
+ "axum",
  "chrono",
  "httpsign",
  "jrd",
- "leptos",
  "leptos_axum",
- "leptos_meta",
- "leptos_router",
+ "leptos_config",
  "mastodon-async-entities",
  "nodeinfo",
  "rand",
@@ -5219,6 +5166,7 @@ dependencies = [
  "tower-http",
  "tracing",
  "upub",
+ "upub-web",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index e1a094b..6805f36 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,7 +24,7 @@ repository = "https://git.alemi.dev/upub.git"
 readme = "README.md"
 
 [[bin]]
-name = "upub"
+name = "upub-bin"
 path = "main.rs"
 
 [dependencies]
@@ -50,8 +50,14 @@ serve = ["dep:upub-routes"]
 migrate = ["dep:upub-migrations"]
 cli = ["dep:upub-cli"]
 worker = ["dep:upub-worker"]
-web = []
-web-build-fe = []
+web = ["upub/web", "upub-routes?/web"]
+
+[[workspace.metadata.leptos]]
+name = "upub"
+bin-package = "upub-bin"
+bin-features = ["serve", "migrate", "cli", "worker", "web"]
+lib-package = "upub-web"
+lib-features = ["leptos-hydrate"]
 
 [profile.wasm-release]
 inherits = "release"
diff --git a/build.rs b/build.rs
deleted file mode 100644
index 3511eff..0000000
--- a/build.rs
+++ /dev/null
@@ -1,12 +0,0 @@
-fn main() {
-	#[cfg(all(feature = "web", feature = "web-build-fe"))]
-	{
-		println!("cargo:warning=running sub-process to build frontend");
-		let status = std::process::Command::new("cargo")
-			.current_dir("web")
-			.args(["build", "--profile=wasm-release", "--target=wasm32-unknown-unknown"])
-			.status()
-			.unwrap();
-		assert!(status.success(), "failed building wasm bundle");
-	}
-}
diff --git a/core/Cargo.toml b/core/Cargo.toml
index d1a5547..a935bae 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -36,3 +36,9 @@ reqwest = { version = "0.12", features = ["json"] }
 apb = { path = "../apb", features = ["unstructured", "orm", "did-core", "activitypub-miscellaneous-terms", "activitypub-fe", "activitypub-counters", "litepub", "ostatus", "toot"] }
 # nodeinfo = "0.0.2" # the version on crates.io doesn't re-export necessary types to build the struct!!!
 nodeinfo = { git = "https://codeberg.org/thefederationinfo/nodeinfo-rs", rev = "e865094804" }
+leptos_config = { version = "0.7", optional = true }
+axum = { version = "0.7", optional = true }
+
+[features]
+default = []
+web = ["dep:leptos_config", "dep:axum"]
diff --git a/core/src/context.rs b/core/src/context.rs
index e066bcc..cd84d62 100644
--- a/core/src/context.rs
+++ b/core/src/context.rs
@@ -193,3 +193,30 @@ pub enum Internal {
 	Activity(i64),
 	Actor(i64),
 }
+
+#[cfg(feature = "web")]
+mod leptos_state {
+	impl axum::extract::FromRef<super::Context> for leptos_config::LeptosOptions {
+		fn from_ref(_ctx: &super::Context) -> leptos_config::LeptosOptions {
+			static CONF: std::sync::OnceLock<leptos_config::LeptosOptions> = std::sync::OnceLock::new();
+			CONF.get_or_init(||
+				leptos_config::LeptosOptions {
+					env: {
+						#[cfg(debug_assertions)]{ leptos_config::Env::DEV }
+						#[cfg(not(debug_assertions))] { leptos_config::Env::PROD }
+					},
+					output_name: "upub_web".into(),
+					site_root: "web/dist".into(),
+					site_pkg_dir: "pkg".into(),
+					site_addr: "127.0.0.1:3000/web".parse().expect("could not create socket addr"), // TODO we don't want to serve? what is this for??
+					reload_port: 3001,
+					reload_external_port: None,
+					reload_ws_protocol: leptos_config::ReloadWSProtocol::WS,
+					not_found_path: "web/404.html".into(),
+					hash_file: "hash.txt".into(),
+					hash_files: true,
+				}
+			).clone()
+		}
+	}
+}
diff --git a/routes/Cargo.toml b/routes/Cargo.toml
index f2bd787..ccbefda 100644
--- a/routes/Cargo.toml
+++ b/routes/Cargo.toml
@@ -22,7 +22,7 @@ jrd = "0.1"
 tracing = "0.1"
 tokio = "1.43"
 reqwest = { version = "0.12", features = ["json"] }
-axum = { version = "0.8", features = ["multipart"] }
+axum = { version = "0.7", features = ["multipart"] }
 tower-http = { version = "0.6", features = ["cors", "trace"] }
 httpsign = { path = "../utils/httpsign/", features = ["axum"] }
 apb = { path = "../apb", features = ["unstructured", "orm", "activitypub-fe", "activitypub-counters", "litepub", "ostatus", "toot", "jsonld"] }
@@ -33,18 +33,12 @@ nodeinfo = { git = "https://codeberg.org/thefederationinfo/nodeinfo-rs", rev = "
 mastodon-async-entities = { version = "1.1.0", optional = true }
 time = { version = "0.3", features = ["serde"], optional = true }
 # frontend
-leptos = { version = "0.7", optional = true }
-leptos_router = { version = "0.7", optional = true }
 leptos_axum = { version = "0.7", optional = true }
-leptos_meta = { version = "0.7", optional = true }
+leptos_config = { version = "0.7", optional = true }
+upub-web = { path = "../web", default-features = false, optional = true }
 
 [features]
-default = ["activitypub", "web"]
+default = ["activitypub"]
 activitypub = []
 mastodon = ["dep:mastodon-async-entities"]
-web = [
-	"dep:leptos",
-	"dep:leptos_router",
-	"dep:leptos_axum",
-	"dep:leptos_meta"
-]
+web = ["dep:leptos_axum", "dep:leptos_config", "dep:upub-web", "upub-web?/leptos-ssr", "upub/web"]
diff --git a/routes/src/activitypub/mod.rs b/routes/src/activitypub/mod.rs
index 523d855..4ff0414 100644
--- a/routes/src/activitypub/mod.rs
+++ b/routes/src/activitypub/mod.rs
@@ -22,7 +22,7 @@ impl super::ActivityPubRouter for Router<upub::Context> {
 			// fetch route, to debug and retreive remote objects
 			.route("/search", get(ap::application::search))
 			.route("/fetch", get(ap::application::ap_fetch))
-			.route("/proxy/{hmac}/{uri}", get(ap::application::cloak_proxy))
+			.route("/proxy/:hmac/:uri", get(ap::application::cloak_proxy))
 			.route("/inbox", post(ap::inbox::post))
 			.route("/inbox", get(ap::inbox::get))
 			.route("/inbox/page", get(ap::inbox::page))
@@ -39,49 +39,49 @@ impl super::ActivityPubRouter for Router<upub::Context> {
 			.route("/.well-known/host-meta", get(ap::well_known::host_meta))
 			.route("/.well-known/nodeinfo", get(ap::well_known::nodeinfo_discovery))
 			.route("/.well-known/oauth-authorization-server", get(ap::well_known::oauth_authorization_server))
-			.route("/nodeinfo/{version}", get(ap::well_known::nodeinfo))
+			.route("/nodeinfo/:version", get(ap::well_known::nodeinfo))
 			// actor routes
-			.route("/actors/{id}", get(ap::actor::view))
-			.route("/actors/{id}/inbox", post(ap::actor::inbox::post))
-			.route("/actors/{id}/inbox", get(ap::actor::inbox::get))
-			.route("/actors/{id}/inbox/page", get(ap::actor::inbox::page))
-			.route("/actors/{id}/outbox", post(ap::actor::outbox::post))
-			.route("/actors/{id}/outbox", get(ap::actor::outbox::get))
-			.route("/actors/{id}/outbox/page", get(ap::actor::outbox::page))
-			.route("/actors/{id}/notifications", get(ap::actor::notifications::get))
-			.route("/actors/{id}/notifications/page", get(ap::actor::notifications::page))
-			.route("/actors/{id}/followers", get(ap::actor::following::get::<false>))
-			.route("/actors/{id}/followers/page", get(ap::actor::following::page::<false>))
-			.route("/actors/{id}/following", get(ap::actor::following::get::<true>))
-			.route("/actors/{id}/following/page", get(ap::actor::following::page::<true>))
-			.route("/actors/{id}/likes", get(ap::actor::likes::get))
-			.route("/actors/{id}/likes/page", get(ap::actor::likes::page))
+			.route("/actors/:id", get(ap::actor::view))
+			.route("/actors/:id/inbox", post(ap::actor::inbox::post))
+			.route("/actors/:id/inbox", get(ap::actor::inbox::get))
+			.route("/actors/:id/inbox/page", get(ap::actor::inbox::page))
+			.route("/actors/:id/outbox", post(ap::actor::outbox::post))
+			.route("/actors/:id/outbox", get(ap::actor::outbox::get))
+			.route("/actors/:id/outbox/page", get(ap::actor::outbox::page))
+			.route("/actors/:id/notifications", get(ap::actor::notifications::get))
+			.route("/actors/:id/notifications/page", get(ap::actor::notifications::page))
+			.route("/actors/:id/followers", get(ap::actor::following::get::<false>))
+			.route("/actors/:id/followers/page", get(ap::actor::following::page::<false>))
+			.route("/actors/:id/following", get(ap::actor::following::get::<true>))
+			.route("/actors/:id/following/page", get(ap::actor::following::page::<true>))
+			.route("/actors/:id/likes", get(ap::actor::likes::get))
+			.route("/actors/:id/likes/page", get(ap::actor::likes::page))
 			.route("/groups", get(ap::groups::get))
 			.route("/groups/page", get(ap::groups::page))
-			// .route("/actors/{id}/audience", get(ap::actor::audience::get))
-			// .route("/actors/{id}/audience/page", get(ap::actor::audience::page))
+			// .route("/actors/:id/audience", get(ap::actor::audience::get))
+			// .route("/actors/:id/audience/page", get(ap::actor::audience::page))
 			// activities
-			.route("/activities/{id}", get(ap::activity::view))
+			.route("/activities/:id", get(ap::activity::view))
 			// hashtags
-			.route("/tags/{id}", get(ap::tags::get))
-			.route("/tags/{id}/page", get(ap::tags::page))
+			.route("/tags/:id", get(ap::tags::get))
+			.route("/tags/:id/page", get(ap::tags::page))
 			// specific object routes
-			.route("/objects/{id}", get(ap::object::view))
-			.route("/objects/{id}/replies", get(ap::object::replies::get))
-			.route("/objects/{id}/replies/page", get(ap::object::replies::page))
-			.route("/objects/{id}/context", get(ap::object::context::get))
-			.route("/objects/{id}/context/page", get(ap::object::context::page))
-			.route("/objects/{id}/likes", get(ap::object::likes::get))
-			.route("/objects/{id}/likes/page", get(ap::object::likes::page))
-			.route("/objects/{id}/shares", get(ap::object::shares::get))
-			.route("/objects/{id}/shares/page", get(ap::object::shares::page))
+			.route("/objects/:id", get(ap::object::view))
+			.route("/objects/:id/replies", get(ap::object::replies::get))
+			.route("/objects/:id/replies/page", get(ap::object::replies::page))
+			.route("/objects/:id/context", get(ap::object::context::get))
+			.route("/objects/:id/context/page", get(ap::object::context::page))
+			.route("/objects/:id/likes", get(ap::object::likes::get))
+			.route("/objects/:id/likes/page", get(ap::object::likes::page))
+			.route("/objects/:id/shares", get(ap::object::shares::get))
+			.route("/objects/:id/shares/page", get(ap::object::shares::page))
 			// file routes
 			.route("/file", post(ap::file::upload))
-			.route("/file/{id}", get(ap::file::download))
-			//.route("/objects/{id}/likes", get(ap::object::likes::get))
-			//.route("/objects/{id}/likes/page", get(ap::object::likes::page))
-			//.route("/objects/{id}/shares", get(ap::object::announces::get))
-			//.route("/objects/{id}/shares/page", get(ap::object::announces::page))
+			.route("/file/:id", get(ap::file::download))
+			//.route("/objects/:id/likes", get(ap::object::likes::get))
+			//.route("/objects/:id/likes/page", get(ap::object::likes::page))
+			//.route("/objects/:id/shares", get(ap::object::announces::get))
+			//.route("/objects/:id/shares/page", get(ap::object::announces::page))
 	}
 }
 
diff --git a/routes/src/auth.rs b/routes/src/auth.rs
index 67bef69..e0e8a93 100644
--- a/routes/src/auth.rs
+++ b/routes/src/auth.rs
@@ -82,6 +82,7 @@ impl Identity {
 
 pub struct AuthIdentity(pub Identity);
 
+#[axum::async_trait]
 impl<S> FromRequestParts<S> for AuthIdentity
 where
 	upub::Context: FromRef<S>,
diff --git a/routes/src/lib.rs b/routes/src/lib.rs
index da97e3a..ec5fad8 100644
--- a/routes/src/lib.rs
+++ b/routes/src/lib.rs
@@ -32,7 +32,7 @@ pub mod mastodon { impl super::MastodonRouter for axum::Router<upub::Context> {}
 
 
 pub trait WebRouter {
-	fn web_routes(self) -> Self where Self: Sized { self }
+	fn web_routes(self, _ctx: &upub::Context) -> Self where Self: Sized { self }
 }
 
 #[cfg(feature = "web")]
@@ -43,7 +43,6 @@ pub mod web {
 	impl super::WebRouter for axum::Router<upub::Context> {}
 }
 
-
 pub async fn serve(ctx: upub::Context, bind: String, shutdown: impl ShutdownToken) -> Result<(), std::io::Error> {
 	use tower_http::{cors::CorsLayer, trace::TraceLayer};
 
@@ -62,8 +61,8 @@ pub async fn serve(ctx: upub::Context, bind: String, shutdown: impl ShutdownToke
 				})
 		)
 		.ap_routes()
-		.mastodon_routes() // no-op if mastodon feature is disabled
-		.web_routes() // no-op if web feature is disabled
+		.mastodon_routes()
+		.web_routes(&ctx)
 		.layer(CorsLayer::permissive())
 		.with_state(ctx);
 
diff --git a/routes/src/web.rs b/routes/src/web.rs
deleted file mode 100644
index a7fff3d..0000000
--- a/routes/src/web.rs
+++ /dev/null
@@ -1,27 +0,0 @@
-use axum::{response::IntoResponse, routing::get, Router};
-
-
-impl super::WebRouter for Router<upub::Context> {
-	fn web_routes(self) -> Self {
-		self
-			.route("/web/assets/upub-web.wasm", get(upub_web_wasm))
-			.route("/web/assets/style.css", get(upub_style_css))
-			.route("/web", get(upub_web_index))
-			.route("/web/", get(upub_web_index))
-			.route("/web/{*any}", get(upub_web_index))
-	}
-}
-
-
-
-async fn upub_web_wasm() -> impl IntoResponse {
-	include_bytes!("../../target/wasm32-unknown-unknown/wasm-release/upub-web.wasm")
-}
-
-async fn upub_style_css() -> impl IntoResponse {
-	include_str!("../../web/assets/style.css")
-}
-
-async fn upub_web_index() -> impl IntoResponse {
-	include_str!("../../web/index.html")
-}
diff --git a/routes/src/web/mod.rs b/routes/src/web/mod.rs
new file mode 100644
index 0000000..97753b4
--- /dev/null
+++ b/routes/src/web/mod.rs
@@ -0,0 +1,11 @@
+use leptos_axum::LeptosRoutes;
+
+impl super::WebRouter for axum::Router<upub::Context> {
+	fn web_routes(self, ctx: &upub::Context) -> Self where Self: Sized {
+		self.leptos_routes(
+			ctx,
+			leptos_axum::generate_route_list(upub_web::App),
+			move || ""
+		)
+	}
+}
diff --git a/utils/httpsign/Cargo.toml b/utils/httpsign/Cargo.toml
index 4ed58ae..205b9b3 100644
--- a/utils/httpsign/Cargo.toml
+++ b/utils/httpsign/Cargo.toml
@@ -19,7 +19,7 @@ thiserror = "2.0"
 tracing = "0.1"
 base64 = "0.22"
 openssl = "0.10" # TODO handle pubkeys with a smaller crate
-axum = { version = "0.8", optional = true }
+axum = { version = "0.7", optional = true }
 
 [features]
 default = []
diff --git a/web/Cargo.toml b/web/Cargo.toml
index d75ede5..d35393c 100644
--- a/web/Cargo.toml
+++ b/web/Cargo.toml
@@ -9,6 +9,9 @@ keywords = ["activitypub", "upub", "json", "web", "wasm"]
 repository = "https://git.alemi.dev/upub.git"
 #readme = "README.md"
 
+[lib]
+crate-type = ["rlib", "cdylib"]
+
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [dependencies]
@@ -24,7 +27,7 @@ serde_json = "1.0"
 serde_default = "0.2"
 serde-inline-default = "0.2"
 dashmap = "6.1"
-leptos = { version = "0.7", features = ["csr", "tracing"] }
+leptos = { version = "0.7", features = ["tracing"] }
 leptos_router = { version = "0.7", features = ["tracing"] }
 leptos-use = "0.15"
 codee = { version = "0.2", features = ["json_serde"] } # WHYYY LEPTOS-USE AKSJFOASHGOAEG
@@ -38,3 +41,9 @@ jrd = "0.1"
 tld = "2.36"
 web-sys = { version = "0.3", features = ["Screen"] }
 regex = "1.11"
+
+[features]
+default = ["leptos-csr"]
+leptos-ssr = ["leptos/ssr"]
+leptos-csr = ["leptos/csr"]
+leptos-hydrate = ["leptos/hydrate"]
diff --git a/web/src/lib.rs b/web/src/lib.rs
index 3ec9508..a35be0b 100644
--- a/web/src/lib.rs
+++ b/web/src/lib.rs
@@ -1,3 +1,5 @@
+#![recursion_limit = "256"] // oh nooo leptos...
+
 mod auth;
 mod app;
 mod components;