diff --git a/.babel.cfg b/.babel.cfg
new file mode 100644
index 0000000..692580d
--- /dev/null
+++ b/.babel.cfg
@@ -0,0 +1 @@
+[jinja2: **.html]
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5f50a1f..ace177e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -48,3 +48,41 @@ jobs:
         with:
           name: doc-html-${{ matrix.branch }}
           path: www/
+
+  translations:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: ["ubuntu-latest", "windows-latest"]
+        # Test minimum supported and latest stable from 3.x series
+        python-version: ["3.9", "3"]
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+          allow-prereleases: true
+          cache: pip
+      - name: Install dependencies
+        run: |
+          pip install --upgrade pip
+          pip install -r requirements.txt
+      - name: Remove locale file for testing
+        shell: bash
+        run: rm -rf locales/pt_BR/
+      - run: python babel_runner.py extract
+      - run: python babel_runner.py init -l pt_BR
+      - run: python babel_runner.py update
+      - run: python babel_runner.py update -l pt_BR
+      - run: python babel_runner.py compile
+      - run: python babel_runner.py compile -l pt_BR
+      - name: Print .pot file
+        shell: bash
+        run: cat locales/messages.pot
+      - name: Print .po file
+        shell: bash
+        run: cat locales/pt_BR/LC_MESSAGES/messages.po
+      - name: list files in locales dir
+        shell: bash
+        run: ls -R locales/
diff --git a/babel_runner.py b/babel_runner.py
new file mode 100755
index 0000000..da4001c
--- /dev/null
+++ b/babel_runner.py
@@ -0,0 +1,120 @@
+#!/usr/bin/venv python3
+"""Script for handling translations with Babel"""
+from __future__ import annotations
+
+import argparse
+import subprocess
+from pathlib import Path
+
+try:
+    import tomllib
+except ImportError:
+    try:
+        import tomli as tomllib
+    except ImportError as ie:
+        raise ImportError(
+            "tomli or tomllib is required to parse pyproject.toml"
+        ) from ie
+
+PROJECT_DIR = Path(__file__).resolve().parent
+
+# Global variables used by pybabel below (paths relative to PROJECT_DIR)
+DOMAIN = "messages"
+COPYRIGHT_HOLDER = "Python Software Foundation"
+LOCALES_DIR = "locales"
+POT_FILE = Path(LOCALES_DIR, f"{DOMAIN}.pot")
+SOURCE_DIR = "python_docs_theme"
+MAPPING_FILE = ".babel.cfg"
+
+
+def get_project_info() -> dict:
+    """Retrieve project's info to populate the message catalog template"""
+    with open(Path(PROJECT_DIR / "pyproject.toml"), "rb") as f:
+        data = tomllib.load(f)
+    return data["project"]
+
+
+def extract_messages() -> None:
+    """Extract messages from all source files into message catalog template"""
+    Path(PROJECT_DIR, LOCALES_DIR).mkdir(parents=True, exist_ok=True)
+    project_data = get_project_info()
+    subprocess.run(
+        [
+            "pybabel",
+            "extract",
+            "-F",
+            MAPPING_FILE,
+            "--copyright-holder",
+            COPYRIGHT_HOLDER,
+            "--project",
+            project_data["name"],
+            "--version",
+            project_data["version"],
+            "--msgid-bugs-address",
+            project_data["urls"]["Issue tracker"],
+            "-o",
+            POT_FILE,
+            SOURCE_DIR,
+        ],
+        cwd=PROJECT_DIR,
+        check=True,
+    )
+
+
+def init_locale(locale: str) -> None:
+    """Initialize a new locale based on existing message catalog template"""
+    pofile = PROJECT_DIR / LOCALES_DIR / locale / "LC_MESSAGES" / f"{DOMAIN}.po"
+    if pofile.exists():
+        print(f"There is already a message catalog for locale {locale}, skipping.")
+        return
+    cmd = ["pybabel", "init", "-i", POT_FILE, "-d", LOCALES_DIR, "-l", locale]
+    subprocess.run(cmd, cwd=PROJECT_DIR, check=True)
+
+
+def update_catalogs(locale: str) -> None:
+    """Update translations from existing message catalogs"""
+    cmd = ["pybabel", "update", "-i", POT_FILE, "-d", LOCALES_DIR]
+    if locale:
+        cmd.extend(["-l", locale])
+    subprocess.run(cmd, cwd=PROJECT_DIR, check=True)
+
+
+def compile_catalogs(locale: str) -> None:
+    """Compile existing message catalogs"""
+    cmd = ["pybabel", "compile", "-d", LOCALES_DIR]
+    if locale:
+        cmd.extend(["-l", locale])
+    subprocess.run(cmd, cwd=PROJECT_DIR, check=True)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "command",
+        choices=["extract", "init", "update", "compile"],
+        help="command to be executed",
+    )
+    parser.add_argument(
+        "-l",
+        "--locale",
+        default="",
+        help="language code (needed for init, optional for update and compile)",
+    )
+
+    args = parser.parse_args()
+    locale = args.locale
+
+    if args.command == "extract":
+        extract_messages()
+    elif args.command == "init":
+        if not locale:
+            parser.error("init requires passing the --locale option")
+        init_locale(locale)
+    elif args.command == "update":
+        update_catalogs(locale)
+    elif args.command == "compile":
+        compile_catalogs(locale)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..18e6c17
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+# for babel_runner.py
+setuptools
+Babel
+Jinja2
+tomli; python_version < "3.10"