diff --git a/.browserslistrc b/.browserslistrc
deleted file mode 100644
index 77e3ec2df95234821a61c32b44c0b59d57ce02f5..0000000000000000000000000000000000000000

--- a/.browserslistrc
+++ /dev/null
@@ -1,9 +0,0 @@
-# Explicitly supported desktop browsers.
-chrome > 63
-firefox > 63
-ie > 10
-
-# Catch other things like mobile browsers. No specific version support for
-# these, just pull the most common ones.
-> 0.5%
-not dead
diff --git a/.eslintrc.yaml b/.eslintrc.yaml
deleted file mode 100644
index 291863dbadaf8475cb20840d7f47c484049f0890..0000000000000000000000000000000000000000

--- a/.eslintrc.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-env:
-  '@beanbag/backbone': true
-  '@beanbag/django': true
-  browser: true
-  jquery: true
-
-
-extends:
-  - 'plugin:@beanbag/recommended'
-
-
-plugins:
-  - '@beanbag'
-
-
-parserOptions:
-  ecmaVersion: 2015
-
-  # All of our JavaScript will be compiled as modules, and then later turned
-  # into browser-safe JavaScript. We want to validate as modules when writing
-  # code.
-  sourceType: module
-
-
-globals:
-  Djblets: writable
diff --git a/.gitignore b/.gitignore
index f6f88c129d3d176e808261fb8cef0bad6f954c6b..74d38e28eee47ed013573347ca9a336220653e8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
 build
 _build
 dist
-djblets/htdocs/*
+djblets/htdocs
 Djblets.egg-info
 docs/djblets/coderef/python
 package-requirements.txt
@@ -10,9 +10,11 @@
 # else in the tree (which may interfere with builds in strange ways), it'll
 # be easier to spot.
 /node_modules
+/djblets/node_modules
 
 .coverage
 .noseids
+.npm-workspaces
 
 *.rej
 *.orig
diff --git a/build-backend.py b/build-backend.py
index 1422458c33b996d9df880314d0ed91be50d4cca6..459d1f58a49fada65f2ab3afaebe0aafcaf84e10 100644
--- a/build-backend.py
+++ b/build-backend.py
@@ -261,14 +261,17 @@
 def _get_build_dependencies() -> list[str]:
     # For a build, we need django-evolution. Rather than hard-coding the
     # dependency twice, pull this from dev-requirements.txt.
-    with open('dev-requirements.txt', 'r') as fp:
+    with open('dev-requirements.txt', mode='r', encoding='utf-8') as fp:
         dev_deps = [
             dep.strip()
             for dep in fp
             if dep.startswith('django_evolution')
         ]
 
-    return build_dependency_list(package_dependencies) + dev_deps
+    return [
+        *build_dependency_list(package_dependencies),
+        *dev_deps,
+    ]
 
 
 def _write_dependencies() -> None:
@@ -276,13 +279,12 @@
 
     This will write to :file:`package-requirements.txt`, so that
     :file:`pyproject.toml` can reference it.
-
-    Context:
-        The file will exist until the context closes.
     """
-    with open('package-requirements.txt', 'w') as fp:
-        fp.write('%s\n'
-                 % '\n'.join(build_dependency_list(package_dependencies)))
+    with open('package-requirements.txt', mode='w', encoding='utf-8') as fp:
+        dependencies = '\n'.join(
+            build_dependency_list(package_dependencies))
+
+        fp.write(f'{dependencies}\n')
 
 
 def _build_data_files(
diff --git a/contrib/internal/build-npm-deps.py b/contrib/internal/build-npm-deps.py
index ed2d8e855f4cfe45ebda25bfdcaadc71ea921ab0..f1870b06f7da60594baf7daf2dd84e0f1c16ff4e 100755
--- a/contrib/internal/build-npm-deps.py
+++ b/contrib/internal/build-npm-deps.py
@@ -5,9 +5,16 @@
     4.0
 """
 
+from __future__ import annotations
+
+import itertools
 import json
 import os
-from typing import Dict, TextIO
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Mapping
+    from typing import TextIO
 
 
 MARKER_START = '# Auto-generated Node.js dependencies {\n'
@@ -20,12 +27,18 @@
 }
 
 
+BUILDKIT_DEP_NAMES = {
+    '@beanbag/frontend-buildkit',
+    '@beanbag/js-buildkit',
+}
+
+
 def _write_deps(
     *,
     fp: TextIO,
     doc: str,
     name: str,
-    deps: Dict[str, str],
+    deps: Mapping[str, str],
 ) -> None:
     """Write dependencies to the file.
 
@@ -45,20 +58,19 @@
         deps (dict):
             The dependencies to write.
     """
+    dependencies = '\n'.join(
+        f"    '{dep_name}': '{dep_ver}',"
+        for dep_name, dep_ver in deps.items()
+
+    )
+
     fp.write(
-        '#: %(doc)s\n'
-        '%(name)s: Dict[str, str] = {\n'
-        '%(deps)s\n'
-        '}\n'
-        '\n'
-        % {
-            'doc': doc,
-            'name': name,
-            'deps': '\n'.join(
-                f"    '{dep_name}': '{dep_ver}',"
-                for dep_name, dep_ver in deps.items()
-            ),
-        })
+        f'#: {doc}\n'
+        f'{name}: Mapping[str, str] = {{\n'
+        f'{dependencies}\n'
+        f'}}\n'
+        f'\n'
+    )
 
 
 def main() -> None:
@@ -70,24 +82,31 @@
     deps_py_path = os.path.join(djblets_dir, 'dependencies.py')
 
     # Load the dependencies and organize them.
-    with open(package_json_path, 'r') as fp:
+    with open(package_json_path, mode='r', encoding='utf-8') as fp:
         package_json = json.load(fp)
 
-    frontend_deps: Dict[str, str] = {}
-    lint_deps: Dict[str, str] = {}
+    frontend_deps: dict[str, str] = {}
+    lint_deps: dict[str, str] = {}
+    npm_deps: dict[str, str] = {}
 
-    for dep_name, dep_ver in sorted(package_json['dependencies'].items()):
+    for dep_name, dep_ver in sorted(
+        itertools.chain(
+            package_json['dependencies'].items(),
+            package_json['devDependencies'].items(),
+        )):
         if dep_name in LINT_DEP_NAMES:
             lint_deps[dep_name] = dep_ver
+        elif dep_name in BUILDKIT_DEP_NAMES:
+            frontend_deps[dep_name] = dep_ver
         else:
-            frontend_deps[dep_name] = dep_ver
+            npm_deps[dep_name] = dep_ver
 
     # Parse out the existing dependencies.py and grab everything outside the
     # markers.
     new_lines_pre: str = ''
     new_lines_post: str = ''
 
-    with open(deps_py_path, 'r') as fp:
+    with open(deps_py_path, mode='r', encoding='utf-8') as fp:
         data = fp.read()
 
         i = data.find(MARKER_START)
@@ -100,7 +119,7 @@
         new_lines_post = data[j + len(MARKER_END) + 1:]
 
     # Write out the new dependencies.py.
-    with open(deps_py_path, 'w') as fp:
+    with open(deps_py_path, mode='w', encoding='utf-8') as fp:
         fp.write(new_lines_pre)
         fp.write(f'{MARKER_START}\n\n')
 
@@ -116,7 +135,18 @@
             name='lint_npm_dependencies',
             deps=lint_deps)
 
-        fp.write(f'\n{MARKER_END}\n')
+        _write_deps(
+            fp=fp,
+            doc='Node dependencies required to package/develop/test Djblets.',
+            name='npm_dependencies',
+            deps=npm_deps)
+
+        fp.write(
+            'npm_dependencies.update(frontend_buildkit_npm_dependencies)\n'
+            'npm_dependencies.update(lint_npm_dependencies)\n'
+            '\n\n'
+        )
+        fp.write(f'{MARKER_END}\n')
         fp.write(new_lines_post)
 
 
diff --git a/djblets/dependencies.py b/djblets/dependencies.py
index efb646eb106f86144048adb93ff34572e6fa64e9..5ff0b2ddb0fbd08c4b83ac704d4ba8a9afe05783 100644
--- a/djblets/dependencies.py
+++ b/djblets/dependencies.py
@@ -11,8 +11,34 @@
 #       packaging and may be needed before any dependencies have been
 #       installed.
 
-import os
-from typing import Dict
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Mapping, Sequence
+    from typing import List, TypedDict, Union
+
+    from typing_extensions import TypeAlias
+
+    class PythonSpecificDependency(TypedDict):
+        """A dependency definition that differs based on Python version.
+
+        Version Added:
+            5.3
+        """
+
+        #: The version limiter of Python the dependency is for.
+        python: str
+
+        #: The version of the dependency to use.
+        version: str
+
+    #: A package dependency version.
+    #:
+    #: Version Added:
+    #:     5.3
+    Dependency: TypeAlias = Union[str, List[PythonSpecificDependency]]
 
 
 ###########################################################################
@@ -43,7 +69,7 @@
 ###########################################################################
 
 #: All dependencies required to install Djblets.
-package_dependencies = {
+package_dependencies: Mapping[str, Dependency] = {
     'cryptography': '>=41.0.7',
     'Django': django_version,
     'django-assert-queries': '~=2.0.1',
@@ -83,52 +109,54 @@
 
 
 #: Dependencies required for static media building.
-frontend_buildkit_npm_dependencies: Dict[str, str] = {
-    '@beanbag/frontend-buildkit': '^1.2.0',
-    '@beanbag/ink': '^0.8.0',
-    '@beanbag/spina': '^3.1.1',
-    '@types/jquery': '^3.5.30',
-    '@types/underscore': '^1.11.4',
-    'backbone': '^1.4.1',
-    'jasmine-core': '^5.0.1',
-    'jquery': '^3.7.1',
-    'jquery-ui': '^1.13.3',
+frontend_buildkit_npm_dependencies: Mapping[str, str] = {
+    '@beanbag/frontend-buildkit': '^2.0.0',
+    '@beanbag/js-buildkit': '^1.0.3',
 }
 
 #: Dependencies required for static media linting.
-lint_npm_dependencies: Dict[str, str] = {
-    '@beanbag/eslint-plugin': '^1.0.2',
-    'eslint': '^8.29.0',
+lint_npm_dependencies: Mapping[str, str] = {
+
 }
 
-
-# } Auto-generated Node.js dependencies
-
-
 #: Node dependencies required to package/develop/test Djblets.
-npm_dependencies = {}
+npm_dependencies: Mapping[str, str] = {
+    '@beanbag/ink': '^0.8.1',
+    'jasmine-core': '^5.12.0',
+    'jquery': '^3.7.1',
+    'jquery-ui': '^1.14.1',
+}
+
 npm_dependencies.update(frontend_buildkit_npm_dependencies)
 npm_dependencies.update(lint_npm_dependencies)
 
 
+# } Auto-generated Node.js dependencies
+
+
 ###########################################################################
 # Packaging utilities
 ###########################################################################
 
-def build_dependency_list(deps, version_prefix=''):
+def build_dependency_list(
+    deps: Mapping[str, Dependency],
+    version_prefix: str = '',
+) -> Sequence[str]:
     """Build a list of dependency specifiers from a dependency map.
 
-    This can be used along with :py:data:`package_dependencies`,
-    :py:data:`npm_dependencies`, or other dependency dictionaries to build a
-    list of dependency specifiers for use on the command line and in
-    :file:`build-backend.py`.
+    This can be used along with :py:data:`package_dependencies`
+    or other dependency dictionaries to build a list of dependency specifiers
+    for use on the command line and in :file:`build-backend.py`.
 
     Args:
         deps (dict):
             A dictionary of dependencies.
 
+        version_prefix (str, optional):
+            The prefix to include on version specifiers.
+
     Returns:
-        list of unicode:
+        list of str:
         A list of dependency specifiers.
     """
     new_deps = []
@@ -136,11 +164,12 @@
     for dep_name, dep_details in deps.items():
         if isinstance(dep_details, list):
             new_deps += [
-                '%s%s%s; python_version%s'
-                % (dep_name, version_prefix, entry['version'], entry['python'])
+                f'{dep_name}{version_prefix}{entry["version"]}; '
+                f'python_version{entry["python"]}'
                 for entry in dep_details
             ]
         else:
-            new_deps.append('%s%s%s' % (dep_name, version_prefix, dep_details))
+            new_deps.append(
+                f'{dep_name}{version_prefix}{dep_details}')
 
     return sorted(new_deps, key=lambda s: s.lower())
diff --git a/djblets/extensions/packaging/static_media.py b/djblets/extensions/packaging/static_media.py
index 780245a13917ae146448c8c36dbcb9e81ba8ce71..0d944029663739ee13a2d01dda107ce5d682be81 100644
--- a/djblets/extensions/packaging/static_media.py
+++ b/djblets/extensions/packaging/static_media.py
@@ -533,6 +533,7 @@
             javascript_bundles=build_context.pipeline_js_bundles,
             stylesheet_bundles=build_context.pipeline_css_bundles,
             use_rollup=True,
+            use_terser=True,
             extra_config={
                 # Tell djblets.pipeline.compiles.less.LessCompiler not to
                 # check for outdated files using its special import script,
diff --git a/djblets/extensions/tests/test_static_media_builder.py b/djblets/extensions/tests/test_static_media_builder.py
index 3269efa9347015c12026909b369ce90f9450137f..d4e927dbd69c1dbb30b2cb11bd9c76aae072f5c3 100644
--- a/djblets/extensions/tests/test_static_media_builder.py
+++ b/djblets/extensions/tests/test_static_media_builder.py
@@ -97,15 +97,16 @@
         assert build_context is not None
 
         djblets_path = Path(djblets.__file__).parent
+        htdocs_path = djblets_path / 'htdocs'
+        static_path = htdocs_path / 'static'
+        node_path = build_context.node_modules_dir
 
         self.assertEqual(builder._build_lessc_args(), [
             '--no-color',
             '--source-map',
             '--js',
-            '--autoprefix',
-            '--include-path=%s:%s:%s' % (djblets_path,
-                                         djblets_path / 'static',
-                                         build_context.node_modules_dir),
+            '--plugin=@beanbag/less-plugin-autoprefix',
+            f'--include-path={htdocs_path}:{static_path}:{node_path}',
             '--global-var=DEBUG=false',
             '--global-var=STATIC_ROOT="/static/"',
         ])
diff --git a/djblets/package.json b/djblets/package.json
index 5fd429a5ebff5ddd4469b390d9bddc96eeda9df8..4273c368d747d3f9a5e053dec0d1d0a2aa700665 100644
--- a/djblets/package.json
+++ b/djblets/package.json
@@ -1,24 +1,14 @@
 {
-  "name": "@beanbag/djblets",
-  "private": "true",
-  "files": [],
-  "scripts": {
-    "dependencies": "./contrib/internal/build-npm-deps.py"
-  },
-  "engines": {
-    "node": ">=18.0.0"
-  },
-  "dependencies": {
-    "@beanbag/eslint-plugin": "^1.0.2",
-    "@beanbag/frontend-buildkit": "^1.2.0",
-    "@beanbag/ink": "^0.8.0",
-    "@beanbag/spina": "^3.1.1",
-    "@types/jquery": "^3.5.30",
-    "@types/underscore": "^1.11.4",
-    "backbone": "^1.4.1",
-    "eslint": "^8.29.0",
-    "jasmine-core": "^5.0.1",
-    "jquery": "^3.7.1",
-    "jquery-ui": "^1.13.3"
-  }
+    "name": "@beanbag/djblets",
+    "private": true,
+    "dependencies": {
+        "@beanbag/ink": "^0.8.1",
+        "jquery": "^3.7.1",
+        "jquery-ui": "^1.14.1"
+    },
+    "devDependencies": {
+        "@beanbag/frontend-buildkit": "^2.0.0",
+        "@beanbag/js-buildkit": "^1.0.3",
+        "jasmine-core": "^5.12.0"
+    }
 }
diff --git a/djblets/pipeline/__init__.py b/djblets/pipeline/__init__.py
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..8eda12311d2f390f04dcc71f83bd43e0e283d4f8 100644
--- a/djblets/pipeline/__init__.py
+++ b/djblets/pipeline/__init__.py
@@ -0,0 +1,43 @@
+"""Improvements to django-pipeline."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, TypedDict
+
+if TYPE_CHECKING:
+    from collections.abc import Mapping, Sequence
+    from typing import Any, Literal
+
+    from typing_extensions import NotRequired
+
+
+class StaticBundle(TypedDict):
+    """Definition for a static bundle.
+
+    This corresponds to the group options listed at
+    https://django-pipeline.readthedocs.io/en/latest/configuration.html#group-options
+
+    Version Added:
+        5.3
+    """
+
+    #: The filename to use for the compiled bundle.
+    output_filename: str
+
+    #: The list of entry-point files that should be included in the bundle.
+    source_filenames: Sequence[str]
+
+    #: The variant to apply to CSS.
+    variant: NotRequired[Literal['datauri'] | None]
+
+    #: A dictionary passed to the compiler's ``compile_file`` method.
+    compiler_options: NotRequired[Mapping[str, Any]]
+
+    #: Extra context to use when rendering the template.
+    extra_context: NotRequired[Mapping[str, Any]]
+
+    #: Whether to include the bundle in the cache manifest.
+    manifest: NotRequired[bool]
+
+    #: The name of the template used to render the HTML tags.
+    template_name: NotRequired[str]
diff --git a/djblets/pipeline/settings.py b/djblets/pipeline/settings.py
index ede0cce18c7fc8ddc1857739a6cbbdfd33fc6ab3..ddd751c02d3d72a7563c41a69d8c82b2857d03fd 100644
--- a/djblets/pipeline/settings.py
+++ b/djblets/pipeline/settings.py
@@ -8,15 +8,30 @@
     2.1
 """
 
+from __future__ import annotations
+
+import logging
 import os
-from typing import Dict, List
+import subprocess
+from pathlib import Path
+from typing import TYPE_CHECKING
 
 from django.core.exceptions import ImproperlyConfigured
-from django.utils.encoding import force_str
+
+from djblets.deprecation import RemovedInDjblets70Warning
+
+if TYPE_CHECKING:
+    from collections.abc import Mapping, Sequence
+    from typing import Any
+
+    from djblets.pipeline import StaticBundle
+
+
+logger = logging.getLogger(__name__)
 
 
 #: Default list of compilers used by Djblets.
-DEFAULT_PIPELINE_COMPILERS: List[str] = [
+DEFAULT_PIPELINE_COMPILERS: list[str] = [
     'djblets.pipeline.compilers.es6.ES6Compiler',
     'djblets.pipeline.compilers.typescript.TypeScriptCompiler',
     'djblets.pipeline.compilers.less.LessCompiler',
@@ -28,17 +43,18 @@
     pipeline_enabled: bool,
     node_modules_path: str,
     static_root: str,
-    javascript_bundles: Dict = {},
-    stylesheet_bundles: Dict = {},
-    compilers: List[str] = DEFAULT_PIPELINE_COMPILERS,
-    babel_extra_plugins: List[str] = [],
-    babel_extra_args: List[str] = [],
-    less_extra_args: List[str] = [],
+    javascript_bundles: (Mapping[str, StaticBundle] | None) = None,
+    stylesheet_bundles: (Mapping[str, StaticBundle] | None) = None,
+    compilers: list[str] = DEFAULT_PIPELINE_COMPILERS,
+    babel_extra_plugins: (Sequence[str] | None) = None,
+    babel_extra_args: (Sequence[str] | None) = None,
+    less_extra_args: (Sequence[str] | None) = None,
     validate_paths: bool = True,
     use_rollup: bool = True,
-    rollup_extra_args: List[str] = [],
-    extra_config: Dict = {},
-) -> Dict:
+    rollup_extra_args: (Sequence[str] | None) = None,
+    extra_config: (Mapping[str, Any] | None) = None,
+    use_terser: bool = False,
+) -> dict[str, Any]:
     """Build a standard set of Pipeline settings.
 
     This can be used to create a ``PIPELINE`` settings dictionary in a
@@ -80,6 +96,13 @@
     ``node_modules_path`` in the :envvar:`NODE_PATH` environment variable.
 
     Version Changed:
+        5.3:
+        * Changed to support a colon-separated list for the
+          ``node_modules_path`` argument.
+        * Added support for `terser <https://terser.org>`.
+        * Added the ``use_terser`` argument.
+
+    Version Changed:
         4.0:
         * Added support for `rollup.js <https://www.rollupjs.org>`.
         * Added ``extra_config``, ``use_rollup`, and ``rollup_extra_args``
@@ -93,11 +116,11 @@
             this if ``DEBUG`` is ``False`` (or, better, use another variable
             indicating a production vs. development environment).
 
-        node_modules_path (unicode):
-            The path to the loal :file:`node_modules` directory for the
-            project.
+        node_modules_path (str):
+            A colon-separated list of paths to :file:`node_modules`
+            directories.
 
-        static_root (unicode):
+        static_root (str):
             The value of the ``settings.STATIC_ROOT``. This must be provided
             explicitly, since :file:`settings.py` is likely the module
             calling this.
@@ -108,16 +131,16 @@
         stylesheet_bundles (list of dict, optional):
             A list of stylesheet bundle packages for Pipeline to handle.
 
-        compilers (list of unicode, optional):
+        compilers (list of str, optional):
             A list of compilers to use for static media.
 
-        babel_extra_plugins (list of unicode, optional):
+        babel_extra_plugins (list of str, optional):
             A list of additional Babel plugins to enable.
 
-        babel_extra_args (list of unicode, optional):
+        babel_extra_args (list of str, optional):
             Extra command line arguments to pass to Babel.
 
-        less_extra_args (list of unicode, optional):
+        less_extra_args (list of str, optional):
             Extra command line arguments to pass to LessCSS.
 
         validate_paths (bool, optional):
@@ -129,13 +152,35 @@
             If the :envvar:`DJBLETS_SKIP_PIPELINE_VALIDATION` environment
             variable is set to ``1``, then this will be forced to ``False``.
             This is primarily used for packaging building.
+
+        use_rollup (bool, optional):
+            Whether to use rollup to assemble JavaScript bundles.
+
+            Version Added:
+                4.0
+
+        rollup_extra_args (list of str, optional):
+            Extra command line arguments to pass to rollup.
+
+            Version Added:
+                4.0
+
+        extra_config (dict, optional):
+            Additional configuration to merge into the resulting dictionary.
+
+            Version Added:
+                4.0
+
+        use_terser (bool, optional):
+            Whether to use Terser instead of UglifyJS.
+
+            Version Added:
+                5.3
+
+    Returns:
+        dict:
+        The pipeline configuration dictionary.
     """
-    bin_path = os.path.join(node_modules_path, '.bin')
-    babel_bin_path = os.path.join(bin_path, 'babel')
-    lessc_bin_path = os.path.join(bin_path, 'lessc')
-    rollup_bin_path = os.path.join(bin_path, 'rollup')
-    uglifyjs_bin_path = os.path.join(bin_path, 'uglifyjs')
-
     use_lessc = (
         'djblets.pipeline.compilers.less.LessCompiler' in compilers or
         'pipeline.compilers.less.LessCompiler' in compilers
@@ -145,67 +190,119 @@
         # Make sure that rollup is before any other JavaScript compilers.
         compilers = [
             'djblets.pipeline.compilers.rollup.RollupCompiler',
-        ] + compilers
+            *compilers,
+        ]
 
     if (validate_paths and
         os.environ.get('DJBLETS_SKIP_PIPELINE_VALIDATION') != '1'):
         # Validate that the necessary dependencies exist for Pipeline.
-        if not os.path.exists(node_modules_path):
-            raise ImproperlyConfigured(
-                'node_modules could not be found at %s'
-                % node_modules_path)
-
-        for binary_path, check in ((babel_bin_path, True),
-                                   (uglifyjs_bin_path, True),
-                                   (lessc_bin_path, use_lessc),
-                                   (rollup_bin_path, use_rollup)):
-            if not os.path.exists(binary_path):
-                raise ImproperlyConfigured(
-                    'The "%s" binary could not be found at %s'
-                    % (os.path.basename(binary_path), binary_path))
-
-    os.environ[str('NODE_PATH')] = force_str(node_modules_path)
+
+        def _try_exec(
+            tool: str,
+            command: Sequence[str],
+        ) -> bool:
+            try:
+                with subprocess.Popen(
+                    ['npm', 'exec', '--', *command],
+                    stdout=subprocess.PIPE,
+                ) as p:
+                    stdout, _stderr = p.communicate(timeout=5)
+
+                    logger.info('Using %s: %s', tool, stdout.decode().strip())
+
+                    return (p.returncode == 0)
+            except Exception as e:
+                logger.error('Unable to execute %s: %s',
+                             subprocess.list2cmdline(command),
+                             e)
+
+                return False
+
+        for path in node_modules_path.split(':'):
+            if not Path(path).exists():
+                raise ImproperlyConfigured(
+                    f'node_modules path "{path}" does not exist')
+
+        for binary, cmdline, check in [
+            ('babel', ['babel', '-V'], True),
+            ('lessc', ['lessc', '-v'], use_lessc),
+            ('rollup', ['rollup', '-v'], use_rollup),
+            ('uglifyjs', ['uglifyjs', '-V'], not use_terser),
+            ('terser', ['terser', '-V'], use_terser),
+        ]:
+            if check and not _try_exec(binary, cmdline):
+                raise ImproperlyConfigured(
+                    f'"{binary}" could not be found in configured '
+                    f'node_modules paths.'
+                )
+
+    os.environ['NODE_PATH'] = node_modules_path
 
     babel_plugins = [
         'dedent',
         'django-gettext',
-    ] + babel_extra_plugins
-
-    config = {
+    ]
+
+    if babel_extra_plugins:
+        babel_plugins.extend(babel_extra_plugins)
+
+    config: dict[str, Any] = {
         'PIPELINE_ENABLED': bool(pipeline_enabled),
         'COMPILERS': compilers,
         'CSS_COMPRESSOR': None,
-        'JS_COMPRESSOR': 'pipeline.compressors.uglifyjs.UglifyJSCompressor',
-        'JAVASCRIPT': javascript_bundles,
-        'STYLESHEETS': stylesheet_bundles,
-        'BABEL_BINARY': babel_bin_path,
+        'JS_COMPRESSOR': 'pipeline.compressors.terser.TerserCompressor',
+        'JAVASCRIPT': javascript_bundles or {},
+        'STYLESHEETS': stylesheet_bundles or {},
+        'BABEL_BINARY': 'npm exec -- babel',
         'BABEL_ARGUMENTS': [
             '--presets', '@babel/preset-env,@babel/preset-typescript',
             '--plugins', ','.join(babel_plugins),
             '-s', 'true',
-        ] + babel_extra_args,
-        'UGLIFYJS_BINARY': uglifyjs_bin_path,
-        'UGLIFYJS_ARGUMENTS': '--compress --mangle',
+            *(babel_extra_args or []),
+        ],
     }
 
+    if use_terser:
+        config.update({
+            'TERSER_BINARY': 'npm exec -- terser',
+            'TERSER_ARGUMENTS': [
+                '--compress',
+                '--mangle',
+                '--keep-classnames',
+                '--keep-fnames',
+            ],
+        })
+    else:
+        RemovedInDjblets70Warning.warn(
+            'Support for UglifyJS is deprecated and will be removed in '
+            'Djblets 7.0. To use terser instead, call '
+            'build_pipeline_settings() with use_terser=True.'
+        )
+        config.update({
+            'UGLIFYJS_BINARY': 'npm exec -- uglifyjs',
+            'UGLIFYJS_ARGUMENTS': '--compress --mangle',
+        })
+
     if use_lessc:
         config.update({
-            'LESS_BINARY': lessc_bin_path,
+            'LESS_BINARY': 'npm exec -- lessc',
             'LESS_ARGUMENTS': [
-                '--include-path=%s:%s' % (static_root, node_modules_path),
+                f'--include-path={static_root}:{node_modules_path}',
                 '--no-color',
                 '--source-map',
                 '--js',
                 '--plugin=@beanbag/less-plugin-autoprefix',
-            ] + less_extra_args,
+                *(less_extra_args or []),
+            ],
         })
 
     if use_rollup:
         config.update({
-            'ROLLUP_ARGUMENTS': rollup_extra_args,
-            'ROLLUP_BINARY': rollup_bin_path,
+            'ROLLUP_ARGUMENTS': rollup_extra_args or [],
+            'ROLLUP_BINARY': 'npm exec -- rollup',
         })
 
-    config.update(extra_config)
+    if extra_config:
+        config.update(extra_config)
 
     return config
diff --git a/djblets/settings.py b/djblets/settings.py
index 545042842fe1243c386c32ff0c327aa5b6f9419b..b564158dead4a5700cd475c30819188afdc29ddc 100644
--- a/djblets/settings.py
+++ b/djblets/settings.py
@@ -6,7 +6,10 @@
 This should generally not be used in a project.
 """
 
+from __future__ import annotations
+
 import os
+from pathlib import Path
 
 from djblets.pipeline.settings import build_pipeline_settings
 from djblets.staticbundles import PIPELINE_JAVASCRIPT, PIPELINE_STYLESHEETS
@@ -44,7 +47,19 @@
 }
 
 
-NODE_PATH = os.path.abspath(os.path.join(DJBLETS_ROOT, '..', 'node_modules'))
+# This will find all instances of `node_modules` from the current directory up
+# into the root. This is particularly useful when working with Djblets
+# alongside other packages using npm workspaces, where the actual node_modules
+# directory may exist (or may be a symbolic link) in a shared parent directory.
+node_paths: list[str] = []
+
+for parent in Path(DJBLETS_ROOT).parents:
+    modules_path = parent / 'node_modules'
+
+    if modules_path.is_dir():
+        node_paths.append(str(modules_path))
+
+NODE_PATH = ':'.join(node_paths)
 
 
 PIPELINE = build_pipeline_settings(
@@ -57,6 +72,7 @@
     javascript_bundles=PIPELINE_JAVASCRIPT,
     stylesheet_bundles=PIPELINE_STYLESHEETS,
     use_rollup=True,
+    use_terser=True,
     validate_paths=not PRODUCTION)
 
 
diff --git a/djblets/static/lib/js/@types/backbone/index.d.ts b/djblets/static/lib/js/@types/backbone/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8d95dc134df6a6e9729ac3f5cc658135574ddf45

--- /dev/null
+++ b/djblets/static/lib/js/@types/backbone/index.d.ts
@@ -0,0 +1,688 @@
+/// <reference types="jquery" />
+/// <reference types="underscore" />
+
+export = Backbone;
+export as namespace Backbone;
+
+declare namespace Backbone {
+    type _Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
+    type _Result<T> = T | (() => T);
+    type _StringKey<T> = keyof T & string;
+    type _NoInfer<T> = T extends infer S ? S : never;
+
+    interface AddOptions extends Silenceable {
+        at?: number | undefined;
+        merge?: boolean | undefined;
+        sort?: boolean | undefined;
+    }
+
+    interface CollectionSetOptions extends Parseable, Silenceable {
+        add?: boolean | undefined;
+        remove?: boolean | undefined;
+        merge?: boolean | undefined;
+        at?: number | undefined;
+        sort?: boolean | undefined;
+    }
+
+    type CollectionComparator<TModel extends (Model | undefined)> =
+        | string
+        | { bivarianceHack(element: TModel): number | string }['bivarianceHack']
+        | { bivarianceHack(compare: TModel, to?: TModel): number }['bivarianceHack'];
+
+    interface CollectionOptions<
+        TModel extends (Model | undefined) = Model
+    > extends CollectionSetOptions {
+        comparator?: CollectionComparator<TModel>;
+        model?: new (...args: any[]) => TModel;
+    }
+
+    type CombinedCollectionConstructorOptions<
+        TExtraCollectionOptions,
+        TModel extends (Model | undefined) = Model,
+    > = CollectionOptions<TModel> & TExtraCollectionOptions;
+
+    type CombinedRouterConstructorOptions<
+        TExtraRouterOptions,
+        TOptions extends RouterOptions = RouterOptions,
+    > = TOptions & TExtraRouterOptions;
+
+    interface HistoryOptions extends Silenceable {
+        pushState?: boolean | undefined;
+        root?: string | undefined;
+        hashChange?: boolean | undefined;
+    }
+
+    interface NavigateOptions {
+        trigger?: boolean | undefined;
+        replace?: boolean | undefined;
+    }
+
+    interface RouterOptions {
+        routes?: _Result<RoutesHash>;
+    }
+
+    interface Silenceable {
+        silent?: boolean | undefined;
+    }
+
+    interface Validable {
+        validate?: boolean | undefined;
+    }
+
+    interface Waitable {
+        wait?: boolean | undefined;
+    }
+
+    interface Parseable {
+        parse?: boolean | undefined;
+    }
+
+    interface PersistenceOptions extends Partial<_Omit<JQueryAjaxSettings, "success" | "error">> {
+        // TODO: Generalize modelOrCollection
+        success?: ((modelOrCollection: any, response: any, options: any) => void) | undefined;
+        error?: ((modelOrCollection: any, response: any, options: any) => void) | undefined;
+        emulateJSON?: boolean | undefined;
+        emulateHTTP?: boolean | undefined;
+    }
+
+    interface ModelConstructorOptions<TModel extends Model = Model> extends ModelSetOptions, Parseable {
+        collection?: Collection<TModel> | undefined;
+    }
+
+    type CombinedModelConstructorOptions<E, M extends Model<any, any, E> = Model> = ModelConstructorOptions<M> & E;
+
+    interface ModelSetOptions extends Silenceable, Validable {}
+
+    interface ModelFetchOptions extends PersistenceOptions, ModelSetOptions, Parseable {}
+
+    interface ModelSaveOptions extends Silenceable, Waitable, Validable, Parseable, PersistenceOptions {
+        patch?: boolean | undefined;
+    }
+
+    interface ModelDestroyOptions extends Waitable, PersistenceOptions {}
+
+    interface CollectionFetchOptions extends PersistenceOptions, Parseable, CollectionSetOptions {
+        reset?: boolean | undefined;
+    }
+
+    type ObjectHash = Record<string, any>;
+
+    interface RoutesHash {
+        [routePattern: string]: string | { (...urlParts: string[]): void };
+    }
+
+    /**
+     * DOM events (used in the events property of a View)
+     */
+    interface EventsHash {
+        [selector: string]: string | { (eventObject: JQuery.TriggeredEvent): void };
+    }
+
+    /**
+     * JavaScript events (used in the methods of the Events interface)
+     */
+    interface EventHandler {
+        (...args: any[]): void;
+    }
+    interface EventMap {
+        [event: string]: EventHandler;
+    }
+
+    const Events: Events;
+    interface Events extends EventsMixin {}
+
+    /**
+     * Helper shorthands for classes that implement the Events interface.
+     * Define your class like this:
+     *
+     * import {
+     *     Events,
+     *     Events_On,
+     *     Events_Off,
+     *     Events_Trigger,
+     *     Events_Listen,
+     *     Events_Stop,
+     * } from 'backbone';
+     *
+     * class YourClass implements Events {
+     *     on: Events_On<YourClass>;
+     *     off: Events_Off<YourClass>;
+     *     trigger: Events_Trigger<YourClass>;
+     *     bind: Events_On<YourClass>;
+     *     unbind: Events_Off<YourClass>;
+     *
+     *     once: Events_On<YourClass>;
+     *     listenTo: Events_Listen<YourClass>;
+     *     listenToOnce: Events_Listen<YourClass>;
+     *     stopListening: Events_Stop<YourClass>;
+     *
+     *     // ... (other methods)
+     * }
+     *
+     * Object.assign(YourClass.prototype, Events);  // can also use _.extend
+     *
+     * If you are just writing a class type declaration that doesn't already
+     * extend some other base class, you can use the EventsMixin instead;
+     * see below.
+     */
+    interface Events_On<BaseT> {
+        <T extends BaseT>(this: T, eventName: string, callback: EventHandler, context?: any): T;
+        <T extends BaseT>(this: T, eventMap: EventMap, context?: any): T;
+    }
+    interface Events_Off<BaseT> {
+        <T extends BaseT>(this: T, eventName?: string | null, callback?: EventHandler | null, context?: any): T;
+        <T extends BaseT>(this: T, eventMap: EventMap, context?: any): T;
+    }
+    interface Events_Trigger<BaseT> {
+        <T extends BaseT>(this: T, eventName: string, ...args: any[]): T;
+    }
+    interface Events_Listen<BaseT> {
+        <T extends BaseT>(this: T, object: any, events: string, callback: EventHandler): T;
+        <T extends BaseT>(this: T, object: any, eventMap: EventMap): T;
+    }
+    interface Events_Stop<BaseT> {
+        <T extends BaseT>(this: T, object?: any, events?: string, callback?: EventHandler): T;
+        <T extends BaseT>(this: T, object: any, eventMap: EventMap): T;
+    }
+
+    /**
+     * Helper to avoid code repetition in type declarations.
+     * Backbone.Events cannot be extended, hence a separate abstract
+     * class with a different name. Both classes and interfaces can
+     * extend from this helper class to reuse the signatures.
+     *
+     * For class type declarations that already extend another base
+     * class, and for actual class definitions, please see the
+     * Events_* interfaces above.
+     */
+    abstract class EventsMixin implements Events {
+        on(eventName: string, callback: EventHandler, context?: any): this;
+        on(eventMap: EventMap, context?: any): this;
+        off(eventName?: string | null, callback?: EventHandler | null, context?: any): this;
+        off(eventMap: EventMap, context?: any): this;
+        trigger(eventName: string, ...args: any[]): this;
+        bind(eventName: string, callback: EventHandler, context?: any): this;
+        bind(eventMap: EventMap, context?: any): this;
+        unbind(eventName?: string, callback?: EventHandler, context?: any): this;
+
+        once(events: string, callback: EventHandler, context?: any): this;
+        once(eventMap: EventMap, context?: any): this;
+        listenTo(object: any, events: string, callback: EventHandler): this;
+        listenTo(object: any, eventMap: EventMap): this;
+        listenToOnce(object: any, events: string, callback: EventHandler): this;
+        listenToOnce(object: any, eventMap: EventMap): this;
+        stopListening(object?: any, events?: string, callback?: EventHandler): this;
+        stopListening(object: any, eventMap: EventMap): this;
+    }
+
+    class ModelBase extends EventsMixin {
+        parse(response: any, options?: any): any;
+        toJSON(options?: any): any;
+        sync(...arg: any[]): JQueryXHR;
+    }
+
+    /**
+     * E - Extensions to the model constructor options. You can accept additional constructor options
+     * by listing them in the E parameter.
+     */
+    class Model<T extends ObjectHash = any, S = ModelSetOptions, E = any> extends ModelBase implements Events {
+        /**
+         * Do not use, prefer TypeScript's extend functionality.
+         */
+        static extend(properties: any, classProperties?: any): any;
+
+        attributes: Partial<T>;
+        changed: Partial<T>;
+        cidPrefix: string;
+        cid: string;
+        collection: Collection<this>;
+
+        private _changing: boolean;
+        private _previousAttributes: Partial<T>;
+        private _pending: boolean;
+
+        /**
+         * Default attributes for the model. It can be an object hash or a method returning an object hash.
+         * For assigning an object hash, do it like this: this.defaults = <any>{ attribute: value, ... };
+         * That works only if you set it in the constructor or the initialize method.
+         */
+        defaults: _Result<Partial<T>>;
+        id: string | number;
+        idAttribute: string;
+        validationError: any;
+
+        /**
+         * Returns the relative URL where the model's resource would be located on the server.
+         */
+        url: _Result<string>;
+
+        urlRoot: _Result<string>;
+
+        /**
+         * For use with models as ES classes. If you define a preinitialize
+         * method, it will be invoked when the Model is first created, before
+         * any instantiation logic is run for the Model.
+         * @see https://backbonejs.org/#Model-preinitialize
+         */
+        preinitialize(
+            attributes?: Partial<T>,
+            options?: CombinedModelConstructorOptions<E, this>,
+        ): void;
+
+        constructor(
+            attributes?: _NoInfer<Partial<T>>,
+            options?: CombinedModelConstructorOptions<_NoInfer<E>>,
+        );
+
+        initialize(
+            attributes?: Partial<T>,
+            options?: CombinedModelConstructorOptions<E, this>,
+        ): void;
+
+        fetch(options?: ModelFetchOptions): JQueryXHR;
+
+        /**
+         * For strongly-typed access to attributes, use the `get` method only privately in public getter properties.
+         * @example
+         * get name(): string {
+         *    return super.get("name");
+         * }
+         */
+        get<A extends _StringKey<T>>(attributeName: A): T[A] | undefined;
+
+        /**
+         * For strongly-typed assignment of attributes, use the `set` method only privately in public setter properties.
+         * @example
+         * set name(value: string) {
+         *    super.set("name", value);
+         * }
+         */
+        set<A extends _StringKey<T>>(attributeName: A, value?: T[A], options?: S): this;
+        set(attributeName: Partial<T>, options?: S): this;
+        set<A extends _StringKey<T>>(attributeName: A | Partial<T>, value?: T[A] | S, options?: S): this;
+
+        /**
+         * Return an object containing all the attributes that have changed, or
+         * false if there are no changed attributes. Useful for determining what
+         * parts of a view need to be updated and/or what attributes need to be
+         * persisted to the server. Unset attributes will be set to undefined.
+         * You can also pass an attributes object to diff against the model,
+         * determining if there *would be* a change.
+         */
+        changedAttributes(attributes?: Partial<T>): Partial<T> | false;
+        clear(options?: Silenceable): this;
+        clone(): Model;
+        destroy(options?: ModelDestroyOptions): JQueryXHR | false;
+        escape(attribute: _StringKey<T>): string;
+        has(attribute: _StringKey<T>): boolean;
+        hasChanged(attribute?: _StringKey<T>): boolean;
+        isNew(): boolean;
+        isValid(options?: any): boolean;
+        previous<A extends _StringKey<T>>(attribute: A): T[A] | null | undefined;
+        previousAttributes(): Partial<T>;
+        save(attributes?: Partial<T> | null, options?: ModelSaveOptions): JQueryXHR;
+        unset(attribute: _StringKey<T>, options?: Silenceable): this;
+        validate(attributes: Partial<T>, options?: any): any;
+        private _validate(attributes: Partial<T>, options: any): boolean;
+
+        // mixins from underscore
+
+        keys(): string[];
+        values(): any[];
+        pairs(): any[];
+        invert(): any;
+        pick<A extends _StringKey<T>>(keys: A[]): Partial<Pick<T, A>>;
+        pick<A extends _StringKey<T>>(...keys: A[]): Partial<Pick<T, A>>;
+        pick(fn: (value: any, key: any, object: any) => any): Partial<T>;
+        omit<A extends _StringKey<T>>(keys: A[]): Partial<_Omit<T, A>>;
+        omit<A extends _StringKey<T>>(...keys: A[]): Partial<_Omit<T, A>>;
+        omit(fn: (value: any, key: any, object: any) => any): Partial<T>;
+        chain(): any;
+        isEmpty(): boolean;
+        matches(attrs: any): boolean;
+    }
+
+    class Collection<
+        TModel extends Model = Model,
+        TExtraCollectionOptions = unknown,
+        TCollectionOptions = CollectionOptions<TModel>
+    > extends ModelBase implements Events {
+        /**
+         * Do not use, prefer TypeScript's extend functionality.
+         */
+        static extend(properties: any, classProperties?: any): any;
+
+        model: (new(...args: any[]) => TModel) | ((...args: any[]) => TModel);
+        models: TModel[];
+        length: number;
+
+        /**
+         * For use with collections as ES classes. If you define a preinitialize
+         * method, it will be invoked when the Collection is first created and
+         * before any instantiation logic is run for the Collection.
+         * @see https://backbonejs.org/#Collection-preinitialize
+         */
+        preinitialize(
+            models?: TModel[] | Array<Record<string, any>>,
+            options?: CombinedCollectionConstructorOptions<
+                TExtraCollectionOptions,
+                TModel
+            >,
+        ): void;
+
+        constructor(
+            models?: TModel[] | Array<Record<string, any>>,
+            options?: CombinedCollectionConstructorOptions<
+                _NoInfer<TExtraCollectionOptions>,
+                TModel
+            >,
+        );
+
+        initialize(
+            models?: TModel[] | Array<Record<string, any>>,
+            options?: CombinedCollectionConstructorOptions<
+                TExtraCollectionOptions,
+                TModel
+            >,
+        ): void;
+
+        fetch(options?: CollectionFetchOptions): JQueryXHR;
+
+        /**
+         * Specify a model attribute name (string) or function that will be used to sort the collection.
+         */
+        comparator: CollectionComparator<TModel>;
+
+        add(models: Array<{} | TModel>, options?: AddOptions): TModel[];
+        add(model: {} | TModel, options?: AddOptions): TModel;
+        at(index: number): TModel;
+        /**
+         * Get a model from a collection, specified by an id, a cid, or by passing in a model.
+         */
+        get(id: number | string | Model): TModel;
+        has(key: number | string | Model): boolean;
+        clone(): this;
+        create(attributes: any, options?: ModelSaveOptions): TModel;
+        pluck(attribute: string): any[];
+        push(model: TModel, options?: AddOptions): TModel;
+        pop(options?: Silenceable): TModel;
+        remove(model: {} | TModel, options?: Silenceable): TModel;
+        remove(models: Array<{} | TModel>, options?: Silenceable): TModel[];
+        reset(models?: Array<{} | TModel>, options?: Silenceable): TModel[];
+
+        /**
+         * The set method performs a "smart" update of the collection with the passed list of models.
+         * If a model in the list isn't yet in the collection it will be added; if the model is already in the
+         * collection its attributes will be merged; and if the collection contains any models that aren't present
+         * in the list, they'll be removed. All of the appropriate "add", "remove", and "change" events are fired as
+         * this happens. Returns the touched models in the collection. If you'd like to customize the behavior, you can
+         * disable it with options: {add: false}, {remove: false}, or {merge: false}.
+         * @param models
+         * @param options
+         */
+        set(models?: Array<{} | TModel>, options?: CollectionSetOptions): TModel[];
+        shift(options?: Silenceable): TModel;
+        sort(options?: Silenceable): this;
+        unshift(model: TModel, options?: AddOptions): TModel;
+        where(properties: any): TModel[];
+        findWhere(properties: any): TModel;
+        modelId(attrs: any): any;
+
+        values(): Iterator<TModel>;
+        keys(): Iterator<any>;
+        entries(): Iterator<[any, TModel]>;
+        [Symbol.iterator](): Iterator<TModel>;
+
+        private _prepareModel(attributes?: any, options?: any): any;
+        private _removeReference(model: TModel): void;
+        private _onModelEvent(event: string, model: TModel, collection: Collection<TModel>, options: any): void;
+        private _isModel(obj: any): obj is Model;
+
+        /**
+         * Return a shallow copy of this collection's models, using the same options as native Array#slice.
+         */
+        slice(min?: number, max?: number): TModel[];
+
+        // mixins from underscore
+
+        all(iterator?: _.ListIterator<TModel, boolean>, context?: any): boolean;
+        any(iterator?: _.ListIterator<TModel, boolean>, context?: any): boolean;
+        chain(): any;
+        collect<TResult>(iterator: _.ListIterator<TModel, TResult>, context?: any): TResult[];
+        contains(value: TModel): boolean;
+        countBy(iterator?: _.ListIterator<TModel, any>): _.Dictionary<number>;
+        countBy(iterator: string): _.Dictionary<number>;
+        detect(iterator: _.ListIterator<TModel, boolean>, context?: any): TModel;
+        difference(others: TModel[]): TModel[];
+        drop(n?: number): TModel[];
+        each(iterator: _.ListIterator<TModel, void>, context?: any): TModel[];
+        every(iterator: _.ListIterator<TModel, boolean>, context?: any): boolean;
+        filter(iterator: _.ListIterator<TModel, boolean>, context?: any): TModel[];
+        find(iterator: _.ListIterator<TModel, boolean>, context?: any): TModel;
+        findIndex(predicate: _.ListIterator<TModel, boolean>, context?: any): number;
+        findLastIndex(predicate: _.ListIterator<TModel, boolean>, context?: any): number;
+        first(): TModel;
+        first(n: number): TModel[];
+        foldl<TResult>(iterator: _.MemoIterator<TModel, TResult>, memo?: TResult, context?: any): TResult;
+        foldr<TResult>(iterator: _.MemoIterator<TModel, TResult>, memo?: TResult, context?: any): TResult;
+        forEach(iterator: _.ListIterator<TModel, void>, context?: any): TModel[];
+        groupBy(iterator: _.ListIterator<TModel, any> | string, context?: any): _.Dictionary<TModel[]>;
+        head(): TModel;
+        head(n: number): TModel[];
+        include(value: TModel): boolean;
+        includes(value: TModel): boolean;
+        indexBy(iterator: _.ListIterator<TModel, any>, context?: any): _.Dictionary<TModel>;
+        indexBy(iterator: string, context?: any): _.Dictionary<TModel>;
+        indexOf(value: TModel, isSorted?: boolean): number;
+        initial(): TModel;
+        initial(n: number): TModel[];
+        inject<TResult>(iterator: _.MemoIterator<TModel, TResult>, memo?: TResult, context?: any): TResult;
+        invoke(methodName: string, ...args: any[]): any;
+        isEmpty(): boolean;
+        last(): TModel;
+        last(n: number): TModel[];
+        lastIndexOf(value: TModel, from?: number): number;
+        map<TResult>(iterator: _.ListIterator<TModel, TResult>, context?: any): TResult[];
+        max(iterator?: _.ListIterator<TModel, any>, context?: any): TModel;
+        min(iterator?: _.ListIterator<TModel, any>, context?: any): TModel;
+        partition(iterator: _.ListIterator<TModel, boolean>): TModel[][];
+        reduce<TResult>(iterator: _.MemoIterator<TModel, TResult>, memo?: TResult, context?: any): TResult;
+        reduceRight<TResult>(iterator: _.MemoIterator<TModel, TResult>, memo?: TResult, context?: any): TResult;
+        reject(iterator: _.ListIterator<TModel, boolean>, context?: any): TModel[];
+        rest(n?: number): TModel[];
+        sample(): TModel;
+        sample(n: number): TModel[];
+        select(iterator: _.ListIterator<TModel, boolean>, context?: any): TModel[];
+        shuffle(): TModel[];
+        size(): number;
+        some(iterator?: _.ListIterator<TModel, boolean>, context?: any): boolean;
+        sortBy(iterator?: _.ListIterator<TModel, any>, context?: any): TModel[];
+        sortBy(iterator: string, context?: any): TModel[];
+        tail(n?: number): TModel[];
+        take(): TModel;
+        take(n: number): TModel[];
+        toArray(): TModel[];
+
+        /**
+         * Sets the url property (or function) on a collection to reference its location on the server.
+         */
+        url: _Result<string>;
+
+        without(...values: TModel[]): TModel[];
+    }
+
+    type RouterCallback = (...args: string[]) => void;
+
+    class Router<
+        TExtraRouterOptions = unknown,
+        TOptions extends RouterOptions = RouterOptions
+    > extends EventsMixin implements Events {
+        /**
+         * Do not use, prefer TypeScript's extend functionality.
+         */
+        static extend(properties: any, classProperties?: any): any;
+
+        /**
+         * Routes hash or a method returning the routes hash that maps URLs with parameters to methods on your Router.
+         * For assigning routes as object hash, do it like this: this.routes = <any>{ "route": callback, ... };
+         * That works only if you set it in the constructor or the initialize method.
+         */
+        routes: _Result<RoutesHash>;
+
+        /**
+         * For use with Router as ES classes. If you define a preinitialize method,
+         * it will be invoked when the Router is first created, before any
+         * instantiation logic is run for the Router.
+         * @see https://backbonejs.org/#Router-preinitialize
+         */
+        preinitialize(
+            options?: CombinedRouterConstructorOptions<TExtraRouterOptions, TOptions>,
+        ): void;
+
+        constructor(
+            options?: CombinedRouterConstructorOptions<
+                _NoInfer<TExtraRouterOptions>,
+                _NoInfer<TOptions>
+            >,
+        );
+
+        initialize(
+            options?: CombinedRouterConstructorOptions<TExtraRouterOptions, TOptions>,
+        ): void;
+
+        route(route: string | RegExp, name: string, callback?: RouterCallback): this;
+        route(route: string | RegExp, callback: RouterCallback): this;
+        navigate(fragment: string, options?: NavigateOptions | boolean): this;
+
+        execute(callback: RouterCallback, args: string[], name: string): void;
+
+        protected _bindRoutes(): void;
+        protected _routeToRegExp(route: string): RegExp;
+        protected _extractParameters(route: RegExp, fragment: string): string[];
+    }
+
+    const history: History;
+
+    class History extends EventsMixin implements Events {
+        handlers: any[];
+        interval: number;
+
+        start(options?: HistoryOptions): boolean;
+
+        getHash(window?: Window): string;
+        getFragment(fragment?: string): string;
+        decodeFragment(fragment: string): string;
+        getSearch(): string;
+        stop(): void;
+        route(route: string | RegExp, callback: (fragment: string) => void): number;
+        checkUrl(e?: any): void;
+        getPath(): string;
+        matchRoot(): boolean;
+        atRoot(): boolean;
+        loadUrl(fragmentOverride?: string): boolean;
+        navigate(fragment: string, options?: any): boolean;
+        static started: boolean;
+        options: any;
+
+        private _updateHash(location: Location, fragment: string, replace: boolean): void;
+    }
+
+    interface ViewOptions<TModel extends (Model | undefined) = Model, TElement extends Element = HTMLElement> {
+        model?: TModel | undefined;
+        // TODO: quickfix, this can't be fixed easy. The collection does not need to have the same model as the parent view.
+        collection?: Collection<any> | undefined; // was: Collection<TModel>;
+        el?: TElement | JQuery | string | undefined;
+        id?: string | undefined;
+        attributes?: Record<string, any> | undefined;
+        className?: string | undefined;
+        tagName?: string | undefined;
+        events?: _Result<EventsHash> | undefined;
+    }
+
+    type CombinedViewConstructorOptions<
+        TExtraViewOptions,
+        TModel extends (Model | undefined) = Model,
+        TElement extends Element = HTMLElement,
+    > = ViewOptions<TModel, TElement> & TExtraViewOptions;
+
+    type ViewEventListener = (event: JQuery.Event) => void;
+
+    class View<
+        TModel extends (Model | undefined) = Model,
+        TElement extends Element = HTMLElement,
+        TExtraViewOptions = unknown,
+        TViewOptions = ViewOptions
+    > extends EventsMixin implements Events {
+        /**
+         * Do not use, prefer TypeScript's extend functionality.
+         */
+        static extend(properties: any, classProperties?: any): any;
+
+        /**
+         * For use with views as ES classes. If you define a preinitialize
+         * method, it will be invoked when the view is first created, before any
+         * instantiation logic is run.
+         * @see https://backbonejs.org/#View-preinitialize
+         */
+        preinitialize(
+            options?: CombinedViewConstructorOptions<
+                TExtraViewOptions, TModel, TElement
+            >,
+        ): void;
+
+        constructor(
+            options?: CombinedViewConstructorOptions<
+                _NoInfer<TExtraViewOptions>, TModel, TElement
+            >,
+        );
+
+        initialize(
+            options?: CombinedViewConstructorOptions<
+                TExtraViewOptions, TModel, TElement
+            >,
+        ): void;
+
+        /**
+         * Events hash or a method returning the events hash that maps events/selectors to methods on your View.
+         * For assigning events as object hash, do it like this: this.events = <any>{ "event:selector": callback, ... };
+         * That works only if you set it in the constructor or the initialize method.
+         */
+        events: _Result<EventsHash>;
+
+        // A conditional type used here to prevent `TS2532: Object is possibly 'undefined'`
+        model: TModel extends Model ? TModel : undefined;
+        collection: Collection<any>;
+        setElement(element: TElement | JQuery): this;
+        id?: _Result<string | undefined>;
+        cid: string;
+        className?: _Result<string | undefined>;
+        tagName: _Result<string>;
+
+        el: TElement;
+        $el: JQuery;
+        attributes: Record<string, any>;
+        $(selector: string): JQuery;
+        render(): this;
+        remove(): this;
+        delegateEvents(events?: _Result<EventsHash>): this;
+        delegate(eventName: string, selector: string, listener: ViewEventListener): this;
+        undelegateEvents(): this;
+        undelegate(eventName: string, selector?: string, listener?: ViewEventListener): this;
+
+        protected _removeElement(): void;
+        protected _setElement(el: TElement | JQuery): void;
+        protected _createElement(tagName: string): void;
+        protected _ensureElement(): void;
+        protected _setAttributes(attributes: Record<string, any>): void;
+    }
+
+    // SYNC
+    function sync(method: string, model: Model | Collection, options?: JQueryAjaxSettings): any;
+    function ajax(options?: JQueryAjaxSettings): JQueryXHR;
+    let emulateHTTP: boolean;
+    let emulateJSON: boolean;
+
+    // Utility
+    function noConflict(): typeof Backbone;
+    let $: JQueryStatic;
+}
diff --git a/djblets/staticbundles.py b/djblets/staticbundles.py
index d2b7a214c06a047f95d063b798831d806706fd4b..20b0f3230b786c066031255fe5723d46a45fc5c3 100644
--- a/djblets/staticbundles.py
+++ b/djblets/staticbundles.py
@@ -1,4 +1,16 @@
-PIPELINE_JAVASCRIPT = {
+"""Static bundles for Djblets."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Mapping
+
+    from djblets.pipeline import StaticBundle
+
+
+PIPELINE_JAVASCRIPT: Mapping[str, StaticBundle] = {
     'djblets-avatars-config': {
         'source_filenames': (
             'djblets/js/avatars/index.ts',
@@ -117,7 +129,7 @@
 }
 
 
-PIPELINE_STYLESHEETS = {
+PIPELINE_STYLESHEETS: Mapping[str, StaticBundle] = {
     'djblets-avatars-config': {
         'source_filenames': (
             'djblets/css/avatars.less',
@@ -166,6 +178,5 @@
             'djblets/css/ui/spinner.less',
         ),
         'output_filename': 'djblets/css/ui.min.css',
-        'absolute_paths': False,
     },
 }
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..a6ff32683cb858f5c4a79adbeec1b6a30d774def

--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,38 @@
+/**
+ * ESLint configuration.
+ *
+ * Version Added:
+ *     5.3
+ */
+
+import beanbag from '@beanbag/eslint-plugin';
+import {
+    defineConfig,
+    globalIgnores,
+} from 'eslint/config';
+import globals from 'globals';
+
+
+export default defineConfig([
+    globalIgnores([
+        'djblets/htdocs/**/*',
+        'djblets/static/lib/js/**',
+    ]),
+    beanbag.configs.recommended,
+    {
+        languageOptions: {
+            ecmaVersion: 'latest',
+            globals: {
+                ...beanbag.globals.backbone,
+                ...beanbag.globals.django,
+                ...globals.browser,
+                ...globals.jquery,
+                Djblets: 'writable',
+                dedent: 'readonly',
+            },
+        },
+        plugins: {
+            '@beanbag': beanbag,
+        },
+    },
+]);
diff --git a/package.json b/package.json
old mode 120000
new mode 100644
index 4abe941a93459a153849dadee5afc8f8576a303b..456b40381b9de04820bcf14b59472f7c0a96e03e

--- a/package.json
+++ b/package.json
@@ -1,1 +1,15 @@
-djblets/package.json
\ No newline at end of file
+{
+    "name": "djblets-root",
+    "private": true,
+    "scripts": {
+        "dependencies": "./contrib/internal/build-npm-deps.py",
+        "lint": "eslint djblets/static/djblets/js"
+    },
+    "browserslist": [
+        "baseline widely available"
+    ],
+    "workspaces": [
+        "djblets",
+        ".npm-workspaces/*"
+    ]
+}
diff --git a/rollup.config.js b/rollup.config.js
index f4c248d69290802e02babc8f7ee2da0d041bef68..9e5badce8dd9a60d3d2c5a23fe5d6b46d559d8e7 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -67,7 +67,6 @@
             modulePaths: [
                 'djblets/static/lib/js',
                 'djblets/static/djblets/js',
-                'node_modules',
             ],
         }),
     ],
diff --git a/tests/settings.py b/tests/settings.py
index c73a98fb04f1322a8495bba3247bb32c84373f93..6c2e7d7163d2516f9605a4d3c4d91eb7e303c488 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -1,4 +1,11 @@
+"""Settings for djblets while running unit tests."""
+
+from __future__ import annotations
+
 import os
+from pathlib import Path
+
+from djblets.pipeline.settings import build_pipeline_settings
 
 DEBUG = True
 PRODUCTION = False
@@ -37,11 +44,6 @@
 
 USE_TZ = True
 
-# Absolute path to the directory that holds media.
-# Example: "/home/media/media.lawrence.com/"
-STATIC_ROOT = os.path.abspath(os.path.join(__file__, '..', 'static'))
-MEDIA_ROOT = os.path.abspath(os.path.join(__file__, '..', 'media'))
-
 MEDIA_URL = '/media/'
 
 # URL that handles the media served from STATIC_ROOT. Make sure to use a
@@ -96,36 +98,31 @@
 # from Django 1.7.
 TEST_RUNNER = 'djblets.testing.testrunners.TestRunner'
 
-base_path = os.path.abspath(os.path.join(os.path.dirname(__file__),
-                                         "..", "djblets"))
-
-
-NODE_PATH = os.path.join(base_path, '..', 'node_modules')
-os.environ['NODE_PATH'] = NODE_PATH
-
-PIPELINE = {
-    'PIPELINE_ENABLED': True,
-    'COMPILERS': [
-        'djblets.pipeline.compilers.es6.ES6Compiler',
-        'djblets.pipeline.compilers.less.LessCompiler',
-    ],
-    'CSS_COMPRESSOR': None,
-    'JS_COMPRESSOR': 'pipeline.compressors.uglifyjs.UglifyJSCompressor',
-    'BABEL_BINARY': os.path.join(NODE_PATH, '.bin', 'babel'),
-    'BABEL_ARGUMENTS': [
-        '--presets', '@babel/preset-env',
-        '--plugins', ['dedent', 'django-gettext'],
-        '-s', 'true',
-    ],
-    'LESS_BINARY': os.path.join(NODE_PATH, 'less', 'bin', 'lessc'),
-    'LESS_ARGUMENTS': [
-        '--no-color',
-        '--source-map',
-        '--js',
-        '--autoprefix',
-    ],
-    'UGLIFYJS_BINARY': os.path.join(NODE_PATH, 'uglify-js', 'bin', 'uglifyjs'),
-}
+
+DJBLETS_ROOT = os.path.abspath(
+    os.path.join(os.path.dirname(__file__), '..', 'djblets'))
+HTDOCS_ROOT = os.path.join(DJBLETS_ROOT, 'htdocs')
+STATIC_ROOT = os.path.join(HTDOCS_ROOT, 'static')
+MEDIA_ROOT = os.path.join(HTDOCS_ROOT, 'media')
+
+
+node_paths: list[str] = []
+
+for parent in Path(DJBLETS_ROOT).parents:
+    modules_path = parent / 'node_modules'
+
+    if modules_path.is_dir():
+        node_paths.append(str(modules_path))
+
+NODE_PATH = ':'.join(node_paths)
+
+PIPELINE = build_pipeline_settings(
+    pipeline_enabled=True,
+    node_modules_path=NODE_PATH,
+    static_root=STATIC_ROOT,
+    use_rollup=True,
+    use_terser=True,
+    validate_paths=True)
 
 
 INSTALLED_APPS = [
@@ -141,7 +138,7 @@
 
 
 STATICFILES_DIRS = (
-    ('djblets', os.path.join(base_path, 'static', 'djblets')),
+    ('djblets', os.path.join(STATIC_ROOT, 'djblets')),
 )
 
 STATICFILES_FINDERS = (
@@ -161,12 +158,12 @@
 }
 
 
-for entry in os.listdir(base_path):
-    fullpath = os.path.join(base_path, entry)
+for entry in os.listdir(DJBLETS_ROOT):
+    fullpath = os.path.join(DJBLETS_ROOT, entry)
 
     if (os.path.isdir(fullpath) and
-        os.path.exists(os.path.join(fullpath, "__init__.py"))):
-        INSTALLED_APPS += ["djblets.%s" % entry]
+        os.path.exists(os.path.join(fullpath, '__init__.py'))):
+        INSTALLED_APPS += [f'djblets.{entry}']
 
 
 INSTALLED_APPS += ['django_evolution']
diff --git a/tsconfig.json b/tsconfig.json
index 622f17b9c0fda5fd68c33380b5f13e93dd213d12..64d8ea5fe3eb4cc41d72e7ff77a4be7c3740ccc0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,22 +1,18 @@
 {
+    "extends": "@beanbag/js-buildkit/tsconfig.json",
     "compilerOptions": {
         "allowJs": true,
         "baseUrl": "./",
         "declaration": false,
-        "esModuleInterop": true,
-        "experimentalDecorators": true,
-        "isolatedModules": true,
-        "moduleResolution": "node",
         "moduleSuffixes": [
             ".es6",
             ""
         ],
         "noEmit": true,
         "paths": {
-            "backbone": ["node_modules/@beanbag/spina/lib/@types/backbone"],
+            "backbone": ["djblets/static/lib/js/@types/backbone"],
             "djblets/*": ["djblets/static/djblets/js/*"]
-        },
-        "target": "ESNext"
+        }
     },
     "include": [
         "djblets/static/lib/js/**/*",
