diff --git a/app/index.tsx b/app/index.tsx
index a9ac00128..cbcc0430e 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -5,7 +5,6 @@ import { Provider } from "mobx-react";
 import * as React from "react";
 import { render } from "react-dom";
 import { Router } from "react-router-dom";
-import { initI18n } from "@shared/i18n";
 import stores from "~/stores";
 import Analytics from "~/components/Analytics";
 import Dialogs from "~/components/Dialogs";
@@ -15,6 +14,7 @@ import ScrollToTop from "~/components/ScrollToTop";
 import Theme from "~/components/Theme";
 import Toasts from "~/components/Toasts";
 import env from "~/env";
+import { initI18n } from "~/utils/i18n";
 import Desktop from "./components/DesktopEventHandler";
 import LazyPolyfill from "./components/LazyPolyfills";
 import Routes from "./routes";
diff --git a/app/test/setup.ts b/app/test/setup.ts
index 64b8c6ee6..2fe3b3669 100644
--- a/app/test/setup.ts
+++ b/app/test/setup.ts
@@ -3,7 +3,7 @@
 import localStorage from "../../__mocks__/localStorage";
 import Enzyme from "enzyme";
 import Adapter from "enzyme-adapter-react-16";
-import { initI18n } from "@shared/i18n";
+import { initI18n } from "../utils/i18n";
 
 initI18n();
 
diff --git a/shared/i18n/index.test.ts b/app/utils/i18n.test.ts
similarity index 90%
rename from shared/i18n/index.test.ts
rename to app/utils/i18n.test.ts
index 64b7fdab5..12752c0ce 100644
--- a/shared/i18n/index.test.ts
+++ b/app/utils/i18n.test.ts
@@ -1,8 +1,8 @@
 import i18n from "i18next";
-import de_DE from "./locales/de_DE/translation.json";
-import en_US from "./locales/en_US/translation.json";
-import pt_PT from "./locales/pt_PT/translation.json";
-import { initI18n } from ".";
+import de_DE from "../../shared/i18n/locales/de_DE/translation.json";
+import en_US from "../../shared/i18n/locales/en_US/translation.json";
+import pt_PT from "../../shared/i18n/locales/pt_PT/translation.json";
+import { initI18n } from "./i18n";
 
 describe("i18n env is unset", () => {
   beforeEach(() => {
diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts
index 6089068bc..6add90954 100644
--- a/app/utils/i18n.ts
+++ b/app/utils/i18n.ts
@@ -17,6 +17,11 @@ import {
   zhCN,
   zhTW,
 } from "date-fns/locale";
+import i18n from "i18next";
+import backend from "i18next-http-backend";
+import { initReactI18next } from "react-i18next";
+import { languages } from "@shared/i18n";
+import { unicodeCLDRtoBCP47, unicodeBCP47toCLDR } from "@shared/utils/date";
 
 const locales = {
   de_DE: de,
@@ -38,8 +43,50 @@ const locales = {
   zh_TW: zhTW,
 };
 
-export function dateLocale(userLocale: string | null | undefined) {
-  return userLocale ? locales[userLocale] : undefined;
+/**
+ * Returns the date-fns locale object for the given user language preference.
+ *
+ * @param language The user language
+ * @returns The date-fns locale.
+ */
+export function dateLocale(language: string | null | undefined) {
+  return language ? locales[language] : undefined;
+}
+
+/**
+ * Initializes i18n library, loading all available translations from the
+ * API backend.
+ *
+ * @param defaultLanguage The default language to use if the user's language
+ * is not supported.
+ * @returns i18n instance
+ */
+export function initI18n(defaultLanguage = "en_US") {
+  const lng = unicodeCLDRtoBCP47(defaultLanguage);
+  i18n
+    .use(backend)
+    .use(initReactI18next)
+    .init({
+      compatibilityJSON: "v3",
+      backend: {
+        // this must match the path defined in routes. It's the path that the
+        // frontend UI code will hit to load missing translations.
+        loadPath: (languages: string[]) =>
+          `/locales/${unicodeBCP47toCLDR(languages[0])}.json`,
+      },
+      interpolation: {
+        escapeValue: false,
+      },
+      react: {
+        useSuspense: false,
+      },
+      lng,
+      fallbackLng: lng,
+      supportedLngs: languages.map(unicodeCLDRtoBCP47),
+      keySeparator: false,
+      returnNull: false,
+    });
+  return i18n;
 }
 
 export { locales };
diff --git a/i18next-parser.config.js b/i18next-parser.config.js
index a5ffa187a..0d79a31af 100644
--- a/i18next-parser.config.js
+++ b/i18next-parser.config.js
@@ -8,7 +8,9 @@ module.exports = {
   defaultNamespace: "translation",
   // Default namespace used in your i18next config
 
-  defaultValue: "",
+  defaultValue(locale, namespace, key) {
+    return key;
+  },
   // Default value to give to empty keys
 
   indentation: 2,
@@ -60,10 +62,6 @@ module.exports = {
   skipDefaultValues: false,
   // Whether to ignore default values.
 
-  useKeysAsDefaultValue: true,
-  // Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
-  // This option takes precedence over the `defaultValue` and `skipDefaultValues` options
-
   verbose: false,
   // Display info about the parsing including some stats
 
@@ -71,15 +69,6 @@ module.exports = {
   // Exit with an exit code of 1 on warnings
 
   customValueTemplate: null,
-  // If you wish to customize the value output the value as an object, you can set your own format.
-  // ${defaultValue} is the default value you set in your translation function.
-  // Any other custom property will be automatically extracted.
-  //
-  // Example:
-  // {
-  //   message: "${defaultValue}",
-  //   description: "${maxLength}", // t('my-key', {maxLength: 150})
-  // }
 
   i18nextOptions: {
     compatibilityJSON: "v3",
diff --git a/package.json b/package.json
index 396ccb565..57e6e6dba 100644
--- a/package.json
+++ b/package.json
@@ -104,6 +104,7 @@
     "gemoji": "6.x",
     "http-errors": "2.0.0",
     "i18next": "^22.4.8",
+    "i18next-fs-backend": "^2.1.1",
     "i18next-http-backend": "^2.1.1",
     "immutable": "^4.0.0",
     "inline-css": "^4.0.1",
diff --git a/server/routes/api/hooks.ts b/server/routes/api/hooks.ts
index 744f02dc2..8c1beb100 100644
--- a/server/routes/api/hooks.ts
+++ b/server/routes/api/hooks.ts
@@ -1,4 +1,5 @@
 import crypto from "crypto";
+import { t } from "i18next";
 import Router from "koa-router";
 import { escapeRegExp } from "lodash";
 import { IntegrationService } from "@shared/types";
@@ -18,6 +19,7 @@ import {
 import SearchHelper from "@server/models/helpers/SearchHelper";
 import { presentSlackAttachment } from "@server/presenters";
 import { APIContext } from "@server/types";
+import { opts } from "@server/utils/i18n";
 import * as Slack from "@server/utils/slack";
 import { assertPresent } from "@server/validation";
 
@@ -150,21 +152,6 @@ router.post("hooks.slack", async (ctx: APIContext) => {
   assertPresent(user_id, "user_id is required");
   verifySlackToken(token);
 
-  // Handle "help" command or no input
-  if (text.trim() === "help" || !text.trim()) {
-    ctx.body = {
-      response_type: "ephemeral",
-      text: "How to use /outline",
-      attachments: [
-        {
-          text:
-            "To search your knowledge base use `/outline keyword`. \nYou’ve already learned how to get help with `/outline help`.",
-        },
-      ],
-    };
-    return;
-  }
-
   let user, team;
   // attempt to find the corresponding team for this request based on the team_id
   team = await Team.findOne({
@@ -225,12 +212,39 @@ router.post("hooks.slack", async (ctx: APIContext) => {
     }
   }
 
+  // Handle "help" command or no input
+  if (text.trim() === "help" || !text.trim()) {
+    ctx.body = {
+      response_type: "ephemeral",
+      text: "How to use /outline",
+      attachments: [
+        {
+          text: t(
+            "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.",
+            {
+              command: `/outline keyword`,
+              command2: `/outline help`,
+              ...opts(user),
+            }
+          ),
+        },
+      ],
+    };
+    return;
+  }
+
   // This should be super rare, how does someone end up being able to make a valid
   // request from Slack that connects to no teams in Outline.
   if (!team) {
     ctx.body = {
       response_type: "ephemeral",
-      text: `Sorry, we couldn’t find an integration for your team. Head to your ${env.APP_NAME} settings to set one up.`,
+      text: t(
+        `Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.`,
+        {
+          ...opts(user),
+          appName: env.APP_NAME,
+        }
+      ),
     };
     return;
   }
@@ -292,7 +306,13 @@ router.post("hooks.slack", async (ctx: APIContext) => {
     query: text,
     results: totalCount,
   });
-  const haventSignedIn = `(It looks like you haven’t signed in to ${env.APP_NAME} yet, so results may be limited)`;
+  const haventSignedIn = t(
+    `It looks like you haven’t signed in to {{ appName }} yet, so results may be limited`,
+    {
+      ...opts(user),
+      appName: env.APP_NAME,
+    }
+  );
 
   // Map search results to the format expected by the Slack API
   if (results.length) {
@@ -312,7 +332,7 @@ router.post("hooks.slack", async (ctx: APIContext) => {
             ? [
                 {
                   name: "post",
-                  text: "Post to Channel",
+                  text: t("Post to Channel", opts(user)),
                   type: "button",
                   value: result.document.id,
                 },
@@ -324,15 +344,24 @@ router.post("hooks.slack", async (ctx: APIContext) => {
 
     ctx.body = {
       text: user
-        ? `This is what we found for "${text}"…`
-        : `This is what we found for "${text}" ${haventSignedIn}…`,
+        ? t(`This is what we found for "{{ term }}"`, {
+            ...opts(user),
+            term: text,
+          })
+        : t(`This is what we found for "{{ term }}"`, {
+            term: text,
+          }) + ` (${haventSignedIn})…`,
       attachments,
     };
   } else {
     ctx.body = {
       text: user
-        ? `No results for "${text}"`
-        : `No results for "${text}" ${haventSignedIn}`,
+        ? t(`No results for "{{ term }}"`, {
+            ...opts(user),
+            term: text,
+          })
+        : t(`No results for "{{ term }}"`, { term: text }) +
+          ` (${haventSignedIn})…`,
     };
   }
 });
diff --git a/server/services/web.ts b/server/services/web.ts
index af89f585e..a57947184 100644
--- a/server/services/web.ts
+++ b/server/services/web.ts
@@ -9,6 +9,7 @@ import mount from "koa-mount";
 import enforceHttps, { xForwardedProtoResolver } from "koa-sslify";
 import env from "@server/env";
 import Logger from "@server/logging/Logger";
+import { initI18n } from "@server/utils/i18n";
 import routes from "../routes";
 import api from "../routes/api";
 import auth from "../routes/auth";
@@ -37,6 +38,8 @@ if (env.CDN_URL) {
 }
 
 export default function init(app: Koa = new Koa()): Koa {
+  initI18n();
+
   if (isProduction) {
     // Force redirect to HTTPS protocol unless explicitly disabled
     if (env.FORCE_HTTPS) {
diff --git a/server/services/worker.ts b/server/services/worker.ts
index 71588f9a5..b2a5449c4 100644
--- a/server/services/worker.ts
+++ b/server/services/worker.ts
@@ -1,6 +1,7 @@
 import Logger from "@server/logging/Logger";
 import { setResource } from "@server/logging/tracer";
 import { traceFunction } from "@server/logging/tracing";
+import { initI18n } from "@server/utils/i18n";
 import {
   globalEventQueue,
   processorEventQueue,
@@ -11,6 +12,8 @@ import processors from "../queues/processors";
 import tasks from "../queues/tasks";
 
 export default function init() {
+  initI18n();
+
   // This queue processes the global event bus
   globalEventQueue.process(
     traceFunction({
diff --git a/server/utils/i18n.ts b/server/utils/i18n.ts
new file mode 100644
index 000000000..0e43db3af
--- /dev/null
+++ b/server/utils/i18n.ts
@@ -0,0 +1,58 @@
+import path from "path";
+import i18n from "i18next";
+import backend from "i18next-fs-backend";
+import { languages } from "@shared/i18n";
+import { unicodeBCP47toCLDR, unicodeCLDRtoBCP47 } from "@shared/utils/date";
+import env from "@server/env";
+import { User } from "@server/models";
+
+/**
+ * Returns i18n options for the given user or the default server language if
+ * no user is provided.
+ *
+ * @param user The user to get options for
+ * @returns i18n options
+ */
+export function opts(user?: User | null) {
+  return {
+    lng: unicodeCLDRtoBCP47(user?.language ?? env.DEFAULT_LANGUAGE),
+  };
+}
+
+/**
+ * Initializes i18n library, loading all available translations from the
+ * filesystem.
+ *
+ * @returns i18n instance
+ */
+export function initI18n() {
+  const lng = unicodeCLDRtoBCP47(env.DEFAULT_LANGUAGE);
+  i18n.use(backend).init({
+    compatibilityJSON: "v3",
+    backend: {
+      loadPath: (language: string) => {
+        return path.resolve(
+          path.join(
+            __dirname,
+            "..",
+            "..",
+            "shared",
+            "i18n",
+            "locales",
+            unicodeBCP47toCLDR(language),
+            "translation.json"
+          )
+        );
+      },
+    },
+    preload: languages.map(unicodeCLDRtoBCP47),
+    interpolation: {
+      escapeValue: false,
+    },
+    lng,
+    fallbackLng: lng,
+    keySeparator: false,
+    returnNull: false,
+  });
+  return i18n;
+}
diff --git a/shared/i18n/index.ts b/shared/i18n/index.ts
index c8ab0a617..5eb04ad5c 100644
--- a/shared/i18n/index.ts
+++ b/shared/i18n/index.ts
@@ -1,8 +1,3 @@
-import i18n from "i18next";
-import backend from "i18next-http-backend";
-import { initReactI18next } from "react-i18next";
-import { unicodeBCP47toCLDR, unicodeCLDRtoBCP47 } from "../utils/date";
-
 // Note: Updating the available languages? Make sure to also update the
 // locales array in app/utils/i18n.js to enable translation for timestamps.
 export const languageOptions = [
@@ -77,32 +72,3 @@ export const languageOptions = [
 ];
 
 export const languages: string[] = languageOptions.map((i) => i.value);
-
-export const initI18n = (defaultLanguage = "en_US") => {
-  const lng = unicodeCLDRtoBCP47(defaultLanguage);
-  i18n
-    .use(backend)
-    .use(initReactI18next)
-    .init({
-      compatibilityJSON: "v3",
-      backend: {
-        // this must match the path defined in routes. It's the path that the
-        // frontend UI code will hit to load missing translations.
-        loadPath: (languages: string[]) =>
-          `/locales/${unicodeBCP47toCLDR(languages[0])}.json`,
-      },
-      interpolation: {
-        escapeValue: false,
-      },
-      react: {
-        useSuspense: false,
-      },
-      lng,
-      fallbackLng: lng,
-      supportedLngs: languages.map(unicodeCLDRtoBCP47),
-      // Uncomment when debugging translation framework, otherwise it's noisy
-      keySeparator: false,
-      returnNull: false,
-    });
-  return i18n;
-};
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index c4af6ff79..3a94a8e42 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -823,5 +823,11 @@
   "This month": "This month",
   "Last month": "Last month",
   "This year": "This year",
+  "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.": "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.",
+  "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.",
+  "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited": "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited",
+  "Post to Channel": "Post to Channel",
+  "This is what we found for \"{{ term }}\"": "This is what we found for \"{{ term }}\"",
+  "No results for \"{{ term }}\"": "No results for \"{{ term }}\"",
   "Uploading": "Uploading"
 }
diff --git a/yarn.lock b/yarn.lock
index 8d03e6319..0873b1a16 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8798,6 +8798,11 @@ husky@^8.0.2:
   resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.2.tgz#5816a60db02650f1f22c8b69b928fd6bcd77a236"
   integrity sha512-Tkv80jtvbnkK3mYWxPZePGFpQ/tT3HNSs/sasF9P2YfkMezDl3ON37YN6jUUI4eTg5LcyVynlb6r4eyvOmspvg==
 
+i18next-fs-backend@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-2.1.1.tgz#07c6393be856c5a398e3dfc1257bf8439841cd89"
+  integrity sha512-FTnj+UmNgT3YRml5ruRv0jMZDG7odOL/OP5PF5mOqvXud2vHrPOOs68Zdk6iqzL47cnnM0ZVkK2BAvpFeDJToA==
+
 i18next-http-backend@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.1.1.tgz#72a21d61c2e96eea9ad45ba1b9dd0090e119709a"