diff --git a/.gitignore b/.gitignore
index 0faf3e4c3ecc7ff5de08a7c56ee313c488b183f6..1ddc534e54d52753adb82a9d41955433c29b3799 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,7 @@ package-lock.json
 .vscode/
 .history/
 *.code-workspace
+
+assets/webpack_bundles/
+node_modules/
+webpack-stats.json
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 3877c6b478d155ca7235ef1dbb20e88e884346c1..8213ace0add948becde82781107e46a8d74e73b8 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -115,6 +115,7 @@ INSTALLED_APPS = [
     "sass_processor",
     "django_any_js",
     "django_yarnpkg",
+    "webpack_loader",
     "django_tables2",
     "maintenance_mode",
     "menu_generator",
@@ -583,16 +584,22 @@ YARN_INSTALLED_APPS = [
     "luxon@^2.3.2",
     "@iconify/iconify@^2.2.1",
     "@iconify/json@^2.1.30",
-    "vue@^2.6.14",
-    "vuetify@^2.6.5",
-    "@mdi/font",
-    "vue-apollo@^3",
-    "apollo-boost",
-    "graphql",
 ]
 
 merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True)
 
+WEBPACK_LOADER = {
+    "DEFAULT": {
+      "CACHE": not DEBUG,
+      "STATS_FILE": os.path.join(BASE_DIR, "..", "webpack-stats.json"),
+      "POLL_INTERVAL": 0.1,
+      "IGNORE": [r".+\.hot-update.js", r".+\.map"],
+    }
+}
+STATICFILES_DIRS = (
+  os.path.join(BASE_DIR, "..", "assets"),
+)
+
 JS_URL = _settings.get("js_assets.url", STATIC_URL)
 JS_ROOT = _settings.get("js_assets.root", NODE_MODULES_ROOT + "/node_modules")
 
diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html
index 6f6e97945bfba403f00b57c5f195f004974c6be8..138be6c4c1e2dd1e0f4225708c783b0c4c6499c5 100644
--- a/aleksis/core/templates/core/vue_base.html
+++ b/aleksis/core/templates/core/vue_base.html
@@ -1,6 +1,7 @@
 {# -*- engine:django -*- #}
 
 {% load i18n menu_generator static sass_tags any_js rules html_helpers %}
+{% load render_bundle from webpack_loader %}
 {% get_current_language as LANGUAGE_CODE %}
 {% get_available_languages as LANGUAGES %}
 
@@ -204,28 +205,13 @@
 </main>
 
 
-{% include_js "luxon" %}
-{#{% include_js "materialize" %}#}
-{% include_js "vue" %}
-{% include_js "vuetify" %}
-<script type="module" src="{% static 'apollo-boost/lib/bundle.esm.js' %}"></script>
-<script type="module" src="{% static 'vue-apollo/dist/vue-apollo.esm.js' %}"></script>
-<script type="module" src="{% static 'graphql-tag/lib/index.js' %}"></script>
-<script type="module" src="{% static 'graphql/index.mjs' %}"></script>
-{% include_js "sortablejs" %}
-{# Fixme: das muss weg ↓ #}
-{% include_js "jquery-sortablejs" %}
-{% absolute_url "graphql" as GRAPHQL_URL %}
-{{ GRAPHQL_URL|json_script:"graphql-url" }}
 {{ request.user.person.preferences.theme__design|json_script:"design-mode" }}
 {{ request.site.preferences.theme__primary|json_script:"primary-color" }}
 {{ request.site.preferences.theme__secondary|json_script:"secondary-color" }}
 {{ LANGUAGE_CODE|json_script:"current-language" }}
 {{ LANGUAGES|json_script:"language-info-list" }}
-{#{{ request.site.preferences.all|json_script:"preferences" }}#}
+{% render_bundle 'main' %}
 <script type="text/javascript" src="{% static 'js/search.js' %}"></script>
-<script type="text/javascript" src="{% static 'js/vue/main_vue.js' %}"></script>
 {% block extra_body %}{% endblock %}
-<script type="module" src="{% static 'js/vue/app.js' %}"></script>
 </body>
 </html>
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index e1a608fe1054bf514ee9ffb55e7924f2b2e5a158..45a2253613b74d97a59c7b7711ae8c5ab31af444 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -73,17 +73,17 @@ def get_app_packages(only_official: bool = False) -> Sequence[str]:
 
 
 def get_app_module(app: str, name: str) -> Optional[ModuleType]:
-    """Get the settings module of an app."""
+    """Get a named module of an app."""
     pkg = ".".join(app.split(".")[:-2])
-    mod_settings = None
+
     while "." in pkg:
         try:
-            return import_module(f"{app}.{name}")
+            return import_module(f"{pkg}.{name}")
         except ImportError:
             # Import errors are non-fatal.
             pkg = ".".join(pkg.split(".")[:-1])
 
-    # The app does not have settings
+    # The app does not have this module
     return None
 
 
diff --git a/aleksis/core/static/js/vue/app.js b/assets/js/app.js
similarity index 100%
rename from aleksis/core/static/js/vue/app.js
rename to assets/js/app.js
diff --git a/aleksis/core/static/js/vue/components/core/CacheNotification.js b/assets/js/components/core/CacheNotification.js
similarity index 100%
rename from aleksis/core/static/js/vue/components/core/CacheNotification.js
rename to assets/js/components/core/CacheNotification.js
diff --git a/aleksis/core/static/js/vue/components/core/LanguageForm.js b/assets/js/components/core/LanguageForm.js
similarity index 100%
rename from aleksis/core/static/js/vue/components/core/LanguageForm.js
rename to assets/js/components/core/LanguageForm.js
diff --git a/aleksis/core/static/js/vue/components/core/MessageBox.js b/assets/js/components/core/MessageBox.js
similarity index 100%
rename from aleksis/core/static/js/vue/components/core/MessageBox.js
rename to assets/js/components/core/MessageBox.js
diff --git a/aleksis/core/static/js/vue/components/core/SidenavSearch.js b/assets/js/components/core/SidenavSearch.js
similarity index 100%
rename from aleksis/core/static/js/vue/components/core/SidenavSearch.js
rename to assets/js/components/core/SidenavSearch.js
diff --git a/assets/js/index.js b/assets/js/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/aleksis/core/static/js/vue/main_vue.js b/assets/js/main_vue.js
similarity index 100%
rename from aleksis/core/static/js/vue/main_vue.js
rename to assets/js/main_vue.js
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..330d4187b3f6ddf92c29fca07facd06436df8961
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+  "devDependencies": {
+    "webpack-bundle-tracker": "^1.6.0",
+    "webpack-cli": "^4.10.0"
+  },
+  "dependencies": {
+    "@mdi/font": "^6.9.96",
+    "apollo-boost": "^0.4.9",
+    "graphql": "^15.8.0",
+    "vue": "^2.7.7",
+    "vue-apollo": "^3.1.0",
+    "vuetify": "^2.6.7"
+  }
+}
diff --git a/pyproject.toml b/pyproject.toml
index ff2ff7f1fc7d4086a19b40adb7c910d76a144b8d..620841ed2fe1c7141bf139538afd46929203ba9b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -130,6 +130,7 @@ django-ical = "^1.8.3"
 django-iconify = "^0.3"
 customidenticon = "^0.1.5"
 graphene-django = "^2.15.0"
+django-webpack-loader = "^1.6.0"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..53399f7ec2f8eadd18935af4410481e7ce62088c
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,15 @@
+const path = require('path');
+const webpack = require('webpack');
+const BundleTracker = require('webpack-bundle-tracker');
+
+module.exports = {
+  context: __dirname,
+  entry: './assets/js/index',
+  output: {
+    path: path.resolve('./assets/webpack_bundles/'),
+    filename: "[name]-[hash].js"
+  },
+  plugins: [
+    new BundleTracker({filename: './webpack-stats.json'})
+  ],
+}