diff --git a/Cargo.lock b/Cargo.lock
index ee60e5f838..107f311058 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -235,6 +235,7 @@ dependencies = [
  "url",
  "urlencoding",
  "web-push",
+ "zhconv",
 ]
 
 [[package]]
@@ -500,6 +501,16 @@ dependencies = [
  "crossbeam-utils",
 ]
 
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "const-oid"
 version = "0.6.2"
@@ -681,6 +692,12 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "daachorse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b7ef7a4be509357f4804d0a22e830daddb48f19fd604e4ad32ddce04a94c36"
+
 [[package]]
 name = "der"
 version = "0.4.5"
@@ -1188,6 +1205,12 @@ version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
+[[package]]
+name = "hex-literal"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
+
 [[package]]
 name = "hkdf"
 version = "0.12.4"
@@ -1518,6 +1541,8 @@ dependencies = [
  "mime",
  "once_cell",
  "polling",
+ "serde",
+ "serde_json",
  "slab",
  "sluice",
  "tracing",
@@ -1526,6 +1551,15 @@ dependencies = [
  "waker-fn",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itertools"
 version = "0.12.1"
@@ -2038,6 +2072,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "object"
 version = "0.36.1"
@@ -2530,7 +2573,7 @@ dependencies = [
  "built",
  "cfg-if",
  "interpolate_name",
- "itertools",
+ "itertools 0.12.1",
  "libc",
  "libfuzzer-sys",
  "log",
@@ -2798,6 +2841,23 @@ dependencies = [
  "untrusted",
 ]
 
+[[package]]
+name = "rustversion"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
+
+[[package]]
+name = "ruzstd"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3ffab8f9715a0d455df4bbb9d21e91135aab3cd3ca187af0cd0c3c3f868fdc"
+dependencies = [
+ "byteorder",
+ "thiserror-core",
+ "twox-hash",
+]
+
 [[package]]
 name = "ryu"
 version = "1.0.18"
@@ -2860,7 +2920,7 @@ dependencies = [
  "serde",
  "serde_json",
  "sqlx",
- "strum",
+ "strum 0.25.0",
  "thiserror",
  "time",
  "tracing",
@@ -3381,12 +3441,34 @@ dependencies = [
  "unicode-properties",
 ]
 
+[[package]]
+name = "strum"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f"
+dependencies = [
+ "strum_macros",
+]
+
 [[package]]
 name = "strum"
 version = "0.25.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
 
+[[package]]
+name = "strum_macros"
+version = "0.24.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "subtle"
 version = "2.6.1"
@@ -3492,6 +3574,26 @@ dependencies = [
  "thiserror-impl",
 ]
 
+[[package]]
+name = "thiserror-core"
+version = "1.0.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c001ee18b7e5e3f62cbf58c7fe220119e68d902bb7443179c0c8aef30090e999"
+dependencies = [
+ "thiserror-core-impl",
+]
+
+[[package]]
+name = "thiserror-core-impl"
+version = "1.0.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4c60d69f36615a077cc7663b9cb8e42275722d23e58a7fa3d2c7f2915d09d04"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.71",
+]
+
 [[package]]
 name = "thiserror-impl"
 version = "1.0.63"
@@ -3531,7 +3633,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
 dependencies = [
  "deranged",
+ "itoa",
+ "libc",
  "num-conv",
+ "num_threads",
  "powerfmt",
  "serde",
  "time-core",
@@ -3733,6 +3838,16 @@ dependencies = [
  "tracing-core",
 ]
 
+[[package]]
+name = "twox-hash"
+version = "1.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675"
+dependencies = [
+ "cfg-if",
+ "static_assertions",
+]
+
 [[package]]
 name = "typenum"
 version = "1.17.0"
@@ -3851,6 +3966,18 @@ version = "0.2.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
 
+[[package]]
+name = "vergen"
+version = "8.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990d9ea5967266ea0ccf413a4aa5c42a93dbcfda9cb49a97de6931726b12566"
+dependencies = [
+ "anyhow",
+ "cfg-if",
+ "rustversion",
+ "time",
+]
+
 [[package]]
 name = "version-compare"
 version = "0.2.0"
@@ -4287,6 +4414,56 @@ dependencies = [
  "syn 2.0.71",
 ]
 
+[[package]]
+name = "zhconv"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a5764e8c3c48dce7dd281cdae65c785536d1da3078b484c2254e7bea7b42323"
+dependencies = [
+ "console_error_panic_hook",
+ "daachorse",
+ "hex-literal",
+ "itertools 0.10.5",
+ "lazy_static",
+ "once_cell",
+ "regex",
+ "ruzstd",
+ "sha2",
+ "strum 0.24.1",
+ "vergen",
+ "wasm-bindgen",
+ "zstd",
+]
+
+[[package]]
+name = "zstd"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "6.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581"
+dependencies = [
+ "libc",
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.12+zstd.1.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
 [[package]]
 name = "zune-core"
 version = "0.4.12"
diff --git a/Cargo.toml b/Cargo.toml
index 784f800384..f91aca2819 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,6 +49,7 @@ tracing-subscriber = { version = "0.3.18", default-features = false }
 url = { version = "2.5.2", default-features = false }
 urlencoding = { version = "2.1.3", default-features = false }
 web-push = { git = "https://github.com/pimeys/rust-web-push.git", rev = "40febe4085e3cef9cdfd539c315e3e945aba0656", default-features = false }
+zhconv = "0.3.1"
 
 # subdependencies
 ## explicitly list OpenSSL to use the vendored version
diff --git a/packages/backend-rs/Cargo.toml b/packages/backend-rs/Cargo.toml
index 59bacd04a4..f6176ec0a4 100644
--- a/packages/backend-rs/Cargo.toml
+++ b/packages/backend-rs/Cargo.toml
@@ -29,7 +29,7 @@ cuid2 = { workspace = true }
 emojis = { workspace = true }
 idna = { workspace = true, features = ["std", "compiled_data"] }
 image = { workspace = true, features = ["avif", "bmp", "gif", "ico", "jpeg", "png", "tiff", "webp"] }
-isahc = { workspace = true, features = ["http2", "text-decoding"] }
+isahc = { workspace = true, features = ["http2", "text-decoding", "json"] }
 nom-exif = { workspace = true }
 once_cell = { workspace = true }
 openssl = { workspace = true, features = ["vendored"] }
@@ -49,6 +49,7 @@ tracing-subscriber = { workspace = true, features = ["ansi"] }
 url = { workspace = true }
 urlencoding = { workspace = true }
 web-push = { workspace = true, features = ["isahc-client"] }
+zhconv = { workspace = true }
 
 [dev-dependencies]
 pretty_assertions = { workspace = true, features = ["std"] }
diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts
index 4892304c5d..68502da54d 100644
--- a/packages/backend-rs/index.d.ts
+++ b/packages/backend-rs/index.d.ts
@@ -1349,6 +1349,13 @@ export declare function toDbReaction(reaction?: string | undefined | null, host?
 
 export declare function toPuny(host: string): string
 
+export declare function translate(text: string, sourceLang: string | undefined | null, targetLang: string): Promise<Translation>
+
+export interface Translation {
+  sourceLang: string
+  text: string
+}
+
 export declare function unwatchNote(watcherId: string, noteId: string): Promise<void>
 
 export declare function updateAntennaCache(): Promise<void>
diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js
index 139739ac00..a5273f7849 100644
--- a/packages/backend-rs/index.js
+++ b/packages/backend-rs/index.js
@@ -443,6 +443,7 @@ module.exports.storageUsage = nativeBinding.storageUsage
 module.exports.stringToAcct = nativeBinding.stringToAcct
 module.exports.toDbReaction = nativeBinding.toDbReaction
 module.exports.toPuny = nativeBinding.toPuny
+module.exports.translate = nativeBinding.translate
 module.exports.unwatchNote = nativeBinding.unwatchNote
 module.exports.updateAntennaCache = nativeBinding.updateAntennaCache
 module.exports.updateAntennasOnNewNote = nativeBinding.updateAntennasOnNewNote
diff --git a/packages/backend-rs/src/misc/mod.rs b/packages/backend-rs/src/misc/mod.rs
index afa3b1396e..4c715f965c 100644
--- a/packages/backend-rs/src/misc/mod.rs
+++ b/packages/backend-rs/src/misc/mod.rs
@@ -17,4 +17,5 @@ pub mod reaction;
 pub mod remove_old_attestation_challenges;
 pub mod should_nyaify;
 pub mod system_info;
+pub mod translate;
 pub mod user;
diff --git a/packages/backend-rs/src/misc/translate.rs b/packages/backend-rs/src/misc/translate.rs
new file mode 100644
index 0000000000..fa19e26feb
--- /dev/null
+++ b/packages/backend-rs/src/misc/translate.rs
@@ -0,0 +1,243 @@
+use crate::{
+    config::{local_server_info, server, CONFIG},
+    util::http_client,
+};
+
+#[macros::errors]
+pub enum Error {
+    #[doc = "database error"]
+    #[error(transparent)]
+    Db(#[from] sea_orm::DbErr),
+    #[error("failed to acquire an HTTP client")]
+    HttpClient(#[from] http_client::Error),
+    #[error("invalid http request body")]
+    InvalidRequestBody(#[from] isahc::http::Error),
+    #[error("http request failed")]
+    HttpRequest(#[from] isahc::Error),
+    #[error("failed to serialize the request body")]
+    Serialize(#[from] serde_json::Error),
+    #[error("Libretranslate API url is not set")]
+    MissingApiUrl,
+    #[error("DeepL API key is not set")]
+    MissingApiKey,
+    #[error("no response")]
+    NoResponse,
+    #[error("translator is not set")]
+    NoTranslator,
+}
+
+#[macros::export(object)]
+pub struct Translation {
+    pub source_lang: String,
+    pub text: String,
+}
+
+#[macros::export]
+pub async fn translate(
+    text: &str,
+    source_lang: Option<&str>,
+    target_lang: &str,
+) -> Result<Translation, Error> {
+    let config = local_server_info().await?;
+
+    let mut translation = if let Some(api_key) = config.deepl_auth_key {
+        deepl_translate::translate(
+            text,
+            source_lang,
+            target_lang,
+            &api_key,
+            config.deepl_is_pro,
+        )
+        .await?
+    } else if let Some(api_url) = config.libre_translate_api_url {
+        libre_translate::translate(
+            text,
+            source_lang,
+            target_lang,
+            &api_url,
+            config.libre_translate_api_key.as_deref(),
+        )
+        .await?
+    } else if let Some(server::DeepLConfig {
+        auth_key, is_pro, ..
+    }) = CONFIG.deepl.as_ref()
+    {
+        deepl_translate::translate(
+            text,
+            source_lang,
+            target_lang,
+            auth_key.as_ref().ok_or(Error::MissingApiKey)?,
+            is_pro.unwrap_or(false),
+        )
+        .await?
+    } else if let Some(server::LibreTranslateConfig {
+        api_url, api_key, ..
+    }) = CONFIG.libre_translate.as_ref()
+    {
+        libre_translate::translate(
+            text,
+            source_lang,
+            target_lang,
+            api_url.as_ref().ok_or(Error::MissingApiUrl)?,
+            api_key.as_deref(),
+        )
+        .await?
+    } else {
+        return Err(Error::NoTranslator);
+    };
+
+    // DeepL translate and LibreTranslate don't provide zh-Hant-TW translations,
+    // so we convert zh-Hans-CN translations into zh-Hant-TW using zhconv.
+    if ["zh-tw", "zh-hant", "zh-hant-tw"].contains(&target_lang.to_ascii_lowercase().as_str()) {
+        translation.text = zhconv::zhconv(&translation.text, zhconv::Variant::ZhTW)
+    }
+
+    Ok(translation)
+}
+
+mod deepl_translate {
+    use crate::util::http_client;
+    use isahc::{AsyncReadResponseExt, Request};
+    use serde::Deserialize;
+    use serde_json::json;
+
+    #[derive(Deserialize)]
+    struct Response {
+        translations: Vec<Translation>,
+    }
+
+    #[derive(Deserialize, Clone)]
+    struct Translation {
+        detected_source_language: Option<String>,
+        text: String,
+    }
+
+    pub(super) async fn translate(
+        text: &str,
+        source_lang: Option<&str>,
+        target_lang: &str,
+        api_key: &str,
+        is_pro: bool,
+    ) -> Result<super::Translation, super::Error> {
+        let client = http_client::client()?;
+
+        let api_url = if is_pro {
+            "https://api.deepl.com/v2/translate"
+        } else {
+            "https://api-free.deepl.com/v2/translate"
+        };
+
+        let mut target_lang = target_lang.split('-').collect::<Vec<&str>>()[0];
+
+        // DeepL API requires us to specify "en-US" or "en-GB" for English
+        // translations ("en" does not work), so we need to address it
+        if target_lang == "en" {
+            target_lang = "en-US";
+        }
+
+        let body = if let Some(source_lang) = source_lang {
+            let source_lang = source_lang.split('-').collect::<Vec<&str>>()[0];
+
+            json!({
+                "text": [text],
+                "source_lang": source_lang,
+                "target_lang": target_lang
+            })
+        } else {
+            json!({
+                "text": [text],
+                "target_lang": target_lang
+            })
+        };
+
+        let request = Request::post(api_url)
+            .header("Authorization", format!("DeepL-Auth-Key {}", api_key))
+            .header("Content-Type", "application/json")
+            .body(serde_json::to_string(&body)?)?;
+
+        let response = client.send_async(request).await?.json::<Response>().await?;
+
+        let result = response
+            .translations
+            .first()
+            .ok_or(super::Error::NoResponse)?
+            .to_owned();
+
+        Ok(super::Translation {
+            source_lang: source_lang
+                .map(|s| s.to_owned())
+                .or(result.detected_source_language)
+                .unwrap_or_else(|| "unknown".to_owned()),
+            text: result.text,
+        })
+    }
+}
+
+mod libre_translate {
+    use crate::util::http_client;
+    use isahc::{AsyncReadResponseExt, Request};
+    use serde::Deserialize;
+    use serde_json::json;
+
+    #[derive(Deserialize, Clone)]
+    #[serde(rename_all = "camelCase")]
+    struct Translation {
+        translated_text: String,
+        detected_language: DetectedLanguage,
+    }
+
+    #[derive(Deserialize, Clone)]
+    struct DetectedLanguage {
+        language: String,
+    }
+
+    pub(super) async fn translate(
+        text: &str,
+        source_lang: Option<&str>,
+        target_lang: &str,
+        api_url: &str,
+        api_key: Option<&str>,
+    ) -> Result<super::Translation, super::Error> {
+        let client = http_client::client()?;
+        let target_lang = target_lang.split('-').collect::<Vec<&str>>()[0];
+
+        let body = if let Some(source_lang) = source_lang {
+            let source_lang = source_lang.split('-').collect::<Vec<&str>>()[0];
+
+            json!({
+                "q": [text],
+                "source": source_lang,
+                "target": target_lang,
+                "format": "text",
+                "alternatives": 1,
+                "api_key": api_key.unwrap_or_default()
+            })
+        } else {
+            json!({
+                "q": [text],
+                "source": "auto",
+                "target": target_lang,
+                "format": "text",
+                "alternatives": 1,
+                "api_key": api_key.unwrap_or_default()
+            })
+        };
+
+        let request = Request::post(api_url)
+            .header("Content-Type", "application/json")
+            .body(serde_json::to_string(&body)?)?;
+
+        let result = client
+            .send_async(request)
+            .await?
+            .json::<Translation>()
+            .await?;
+
+        Ok(super::Translation {
+            source_lang: source_lang
+                .map(|s| s.to_owned())
+                .unwrap_or(result.detected_language.language),
+            text: result.translated_text,
+        })
+    }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 47e7e3a653..3719b614f7 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -50,7 +50,6 @@
 		"date-fns": "3.6.0",
 		"decompress": "4.2.1",
 		"deep-email-validator": "0.1.21",
-		"deepl-node": "1.13.0",
 		"escape-regexp": "0.0.1",
 		"feed": "4.2.2",
 		"file-type": "19.2.0",
@@ -84,7 +83,6 @@
 		"nested-property": "4.0.0",
 		"node-fetch": "3.3.2",
 		"nodemailer": "6.9.14",
-		"opencc-js": "1.0.5",
 		"otpauth": "9.3.1",
 		"parse5": "7.1.2",
 		"pg": "8.12.0",
diff --git a/packages/backend/src/misc/translate.ts b/packages/backend/src/misc/translate.ts
deleted file mode 100644
index d1bcd5e72e..0000000000
--- a/packages/backend/src/misc/translate.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import fetch from "node-fetch";
-import { Converter } from "opencc-js";
-import { getAgentByUrl } from "@/misc/fetch.js";
-import { fetchMeta } from "backend-rs";
-import type { PostLanguage } from "firefish-js";
-import * as deepl from "deepl-node";
-
-// DeepL translate and LibreTranslate don't provide
-// zh-Hant-TW translations, so we convert zh-Hans-CN
-// translations into zh-Hant-TW using opencc-js.
-function convertChinese(convert: boolean, src: string) {
-	if (!convert) return src;
-	const converter = Converter({ from: "cn", to: "twp" });
-	return converter(src);
-}
-
-function stem(lang: PostLanguage): string {
-	let toReturn = lang as string;
-	if (toReturn.includes("-")) toReturn = toReturn.split("-")[0];
-	if (toReturn.includes("_")) toReturn = toReturn.split("_")[0];
-	return toReturn;
-}
-
-export async function translate(
-	text: string,
-	from: PostLanguage | null,
-	to: PostLanguage,
-) {
-	const instance = await fetchMeta();
-
-	if (instance.deeplAuthKey == null && instance.libreTranslateApiUrl == null) {
-		throw Error("No translator is set up on this server.");
-	}
-
-	const source = from == null ? null : stem(from);
-	const target = stem(to);
-
-	if (instance.libreTranslateApiUrl != null) {
-		const jsonBody = {
-			q: text,
-			source: source ?? "auto",
-			target,
-			format: "text",
-			api_key: instance.libreTranslateApiKey ?? "",
-		};
-
-		const url = new URL(instance.libreTranslateApiUrl);
-		if (url.pathname.endsWith("/")) {
-			url.pathname = url.pathname.slice(0, -1);
-		}
-		if (!url.pathname.endsWith("/translate")) {
-			url.pathname += "/translate";
-		}
-		const res = await fetch(url.toString(), {
-			method: "POST",
-			headers: {
-				"Content-Type": "application/json",
-			},
-			body: JSON.stringify(jsonBody),
-			agent: getAgentByUrl,
-		});
-
-		const json = (await res.json()) as {
-			detectedLanguage?: {
-				confidence: number;
-				language: string;
-			};
-			translatedText: string;
-		};
-
-		return {
-			sourceLang: source ?? json.detectedLanguage?.language,
-			text: convertChinese(
-				["zh-hant", "zh-TW"].includes(to),
-				json.translatedText,
-			),
-		};
-	}
-
-	const deeplTranslator = new deepl.Translator(instance.deeplAuthKey ?? "");
-	const result = await deeplTranslator.translateText(
-		text,
-		source as deepl.SourceLanguageCode | null,
-		// DeepL API requires us to specify "en-US" or "en-GB" for English
-		// translations ("en" does not work), so we need to address it
-		(target === "en" ? to : target) as deepl.TargetLanguageCode,
-	);
-
-	return {
-		sourceLang: source ?? result.detectedSourceLang,
-		text: convertChinese(["zh-hant", "zh-TW"].includes(to), result.text),
-	};
-}
diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts
index 8b84baa0b2..c939ab231a 100644
--- a/packages/backend/src/server/api/endpoints/notes/translate.ts
+++ b/packages/backend/src/server/api/endpoints/notes/translate.ts
@@ -1,7 +1,6 @@
 import { ApiError } from "@/server/api/error.js";
 import { getNote } from "@/server/api/common/getters.js";
-import { translate } from "@/misc/translate.js";
-import type { PostLanguage } from "firefish-js";
+import { translate } from "backend-rs";
 import define from "@/server/api/define.js";
 
 export const meta = {
@@ -47,7 +46,7 @@ export default define(meta, paramDef, async (ps, user) => {
 
 	return translate(
 		note.text,
-		note.lang as PostLanguage | null,
-		ps.targetLang as PostLanguage,
+		note.lang as string | null,
+		ps.targetLang,
 	);
 });
diff --git a/packages/backend/src/server/api/mastodon/helpers/note.ts b/packages/backend/src/server/api/mastodon/helpers/note.ts
index 81df7c1924..15ae9b5d4a 100644
--- a/packages/backend/src/server/api/mastodon/helpers/note.ts
+++ b/packages/backend/src/server/api/mastodon/helpers/note.ts
@@ -40,7 +40,7 @@ import {
 	getStubMastoContext,
 	type MastoContext,
 } from "@/server/api/mastodon/index.js";
-import { translate } from "@/misc/translate.js";
+import { translate } from "backend-rs";
 import { createScheduledNoteJob } from "@/queue/index.js";
 
 export class NoteHelpers {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 076a935d93..8f83d81d26 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -129,9 +129,6 @@ importers:
       deep-email-validator:
         specifier: 0.1.21
         version: 0.1.21
-      deepl-node:
-        specifier: 1.13.0
-        version: 1.13.0
       escape-regexp:
         specifier: 0.0.1
         version: 0.0.1
@@ -231,9 +228,6 @@ importers:
       nodemailer:
         specifier: 6.9.14
         version: 6.9.14
-      opencc-js:
-        specifier: 1.0.5
-        version: 1.0.5
       otpauth:
         specifier: 9.3.1
         version: 9.3.1
@@ -1001,13 +995,11 @@ packages:
   '@biomejs/cli-darwin-arm64@1.8.3':
     resolution: {integrity: sha512-9DYOjclFpKrH/m1Oz75SSExR8VKvNSSsLnVIqdnKexj6NwmiMlKk94Wa1kZEdv6MCOHGHgyyoV57Cw8WzL5n3A==}
     engines: {node: '>=14.21.3'}
-    cpu: [arm64]
     os: [darwin]
 
   '@biomejs/cli-darwin-x64@1.8.3':
     resolution: {integrity: sha512-UeW44L/AtbmOF7KXLCoM+9PSgPo0IDcyEUfIoOXYeANaNXXf9mLUwV1GeF2OWjyic5zj6CnAJ9uzk2LT3v/wAw==}
     engines: {node: '>=14.21.3'}
-    cpu: [x64]
     os: [darwin]
 
   '@biomejs/cli-linux-arm64-musl@1.8.3':
@@ -1019,7 +1011,6 @@ packages:
   '@biomejs/cli-linux-arm64@1.8.3':
     resolution: {integrity: sha512-fed2ji8s+I/m8upWpTJGanqiJ0rnlHOK3DdxsyVLZQ8ClY6qLuPc9uehCREBifRJLl/iJyQpHIRufLDeotsPtw==}
     engines: {node: '>=14.21.3'}
-    cpu: [arm64]
     os: [linux]
 
   '@biomejs/cli-linux-x64-musl@1.8.3':
@@ -1031,7 +1022,6 @@ packages:
   '@biomejs/cli-linux-x64@1.8.3':
     resolution: {integrity: sha512-I8G2QmuE1teISyT8ie1HXsjFRz9L1m5n83U1O6m30Kw+kPMPSKjag6QGUn+sXT8V+XWIZxFFBoTDEDZW2KPDDw==}
     engines: {node: '>=14.21.3'}
-    cpu: [x64]
     os: [linux]
 
   '@biomejs/cli-win32-arm64@1.8.3':
@@ -3662,10 +3652,6 @@ packages:
   deep-equal@1.0.1:
     resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
 
-  deepl-node@1.13.0:
-    resolution: {integrity: sha512-pm8Al5B+/fRHiIKoreoSmv2RlXidF18+CznhtLILiYcj3EbxZpIhxWO8cgXCCsCTrUDMAbScIl8CuH3AqLPpGg==}
-    engines: {node: '>=12.0'}
-
   deepmerge@4.3.1:
     resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
     engines: {node: '>=0.10.0'}
@@ -4085,10 +4071,6 @@ packages:
     resolution: {integrity: sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==}
     engines: {node: '>= 18'}
 
-  form-data@3.0.1:
-    resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
-    engines: {node: '>= 6'}
-
   form-data@4.0.0:
     resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
     engines: {node: '>= 6'}
@@ -5032,10 +5014,6 @@ packages:
     resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
     engines: {node: '>=10'}
 
-  loglevel@1.9.1:
-    resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==}
-    engines: {node: '>= 0.6.0'}
-
   long@5.2.3:
     resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
 
@@ -5363,9 +5341,6 @@ packages:
   only@0.0.2:
     resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==}
 
-  opencc-js@1.0.5:
-    resolution: {integrity: sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==}
-
   opencollective-postinstall@2.0.3:
     resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
     hasBin: true
@@ -9852,15 +9827,6 @@ snapshots:
 
   deep-equal@1.0.1: {}
 
-  deepl-node@1.13.0:
-    dependencies:
-      '@types/node': 20.14.11
-      axios: 1.7.2
-      form-data: 3.0.1
-      loglevel: 1.9.1
-    transitivePeerDependencies:
-      - debug
-
   deepmerge@4.3.1: {}
 
   defer-to-connect@2.0.1: {}
@@ -10292,12 +10258,6 @@ snapshots:
 
   form-data-encoder@4.0.2: {}
 
-  form-data@3.0.1:
-    dependencies:
-      asynckit: 0.4.0
-      combined-stream: 1.0.8
-      mime-types: 2.1.35
-
   form-data@4.0.0:
     dependencies:
       asynckit: 0.4.0
@@ -11559,8 +11519,6 @@ snapshots:
       chalk: 4.1.2
       is-unicode-supported: 0.1.0
 
-  loglevel@1.9.1: {}
-
   long@5.2.3: {}
 
   lowercase-keys@2.0.0: {}
@@ -11868,8 +11826,6 @@ snapshots:
 
   only@0.0.2: {}
 
-  opencc-js@1.0.5: {}
-
   opencollective-postinstall@2.0.3: {}
 
   opentype.js@0.4.11: {}