diff --git a/.eslintrc.js b/.eslintrc.js
index 4c2043012828bd16438eb4f36472ad48460eb6e4..3bdc1f231b3a75f10ae3ba0687e8c649d7218082 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -9,7 +9,6 @@ module.exports = {
     "vue/multi-word-component-names": "off",
   },
   env: {
-    browser: true,
-    node: true,
+    es2021: true,
   },
 };
diff --git a/.gitignore b/.gitignore
index dd85b53f78b12d78a2ff1053f4ab17e2601a62b2..9f60735a5e85703360c8669a34fa01622a079afd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,5 @@ yarn.lock
 *.code-workspace
 
 /cache
+/node_modules
+.vite
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 22864314148d1bada0cccb4ef5f828235a9b8cd5..6efe90b75c7e1ae7ba4a387bcc08417172eab585 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,12 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Deprecated
+~~~~~~~~~~
+
+* The `webpack_bundle` management command is replaced by the new `vite`
+  command. The `webpack_bundle` command will be removed in AlekSIS-Core 4.0.
+
 Added
 ~~~~~
 
@@ -19,6 +25,8 @@ Changed
 ~~~~~~~
 
 * Rewrite of frontend using Vuetify
+  * The runuwsgi dev server now starts a Vite dev server with HMR in the
+    background
 * OIDC scope "profile" now exposes the avatar instead of the official photo
 * Based on Django 4.0
   * Use built-in Redis cache backend
@@ -27,6 +35,7 @@ Changed
   requests
 * Incorporate SPDX license list for app licenses on About page
 * [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check`
+* Frontend bundling migrated from Webpack to Vite
 
 Fixed
 ~~~~~
diff --git a/Dockerfile b/Dockerfile
index 914ee330f0d7e6646ba62c4d630747501b72d4f4..44159bb3fd185d4eb11224e48139319c9468b019 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -75,7 +75,7 @@ CMD ["/usr/local/bin/aleksis-docker-startup"]
 
 # Install assets
 FROM core as assets
-RUN eatmydata aleksis-admin webpack_bundle; \
+RUN eatmydata aleksis-admin vite build; \
     eatmydata aleksis-admin collectstatic --no-input; \
     rm -rf /usr/local/share/.cache
 # FIXME Introduce deletion after we don't need materializecss anymore for SASS
@@ -124,7 +124,7 @@ ONBUILD RUN set -e; \
             if [ -n "$APPS" ]; then \
                 eatmydata pip install $APPS; \
             fi; \
-            eatmydata aleksis-admin webpack_bundle; \
+            eatmydata aleksis-admin vite build; \
             eatmydata aleksis-admin collectstatic --no-input; \
             rm -rf /usr/local/share/.cache; \
             eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \
diff --git a/aleksis/core/assets/app.js b/aleksis/core/assets/app.js
index ed2e2caafa8f6cd2078f956d05600c844f4cf842..53eb31376cf1a3b949d8ba4aae3e9d95d2abdb03 100644
--- a/aleksis/core/assets/app.js
+++ b/aleksis/core/assets/app.js
@@ -86,6 +86,7 @@ import MessageBox from "./components/MessageBox.vue";
 import NotificationList from "./components/notifications/NotificationList.vue";
 import SidenavSearch from "./components/SidenavSearch.vue";
 import CeleryProgressBottom from "./components/celery_progress/CeleryProgressBottom.vue";
+import gqlSystemProperties from "./systemProperties.graphql";
 
 Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it
 
@@ -120,7 +121,7 @@ const app = new Vue({
     },
   }),
   apollo: {
-    systemProperties: require("./systemProperties.graphql"),
+    systemProperties: gqlSystemProperties,
   },
   watch: {
     systemProperties: function (newProperties) {
diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue
index 58bf7bf6d718302279cd9d2d5d9451b6bdf9a644..ea044a9b6a0bca4ed7a84fdcc4ae4ef156826e87 100644
--- a/aleksis/core/assets/components/SidenavSearch.vue
+++ b/aleksis/core/assets/components/SidenavSearch.vue
@@ -1,4 +1,6 @@
 <script>
+import gqlSearchSnippets from "./searchSnippets.graphql";
+
 export default {
   methods: {
     submit: function () {
@@ -22,7 +24,7 @@ export default {
 
 <template>
   <ApolloQuery
-    :query="require('./searchSnippets.graphql')"
+    :query="gqlSearchSnippets"
     :variables="{
       q,
     }"
diff --git a/aleksis/core/assets/components/about/InstalledAppsList.vue b/aleksis/core/assets/components/about/InstalledAppsList.vue
index 491e3df15ece49cf736f037212a0bc668a924bed..704eedc97e172f7f840f4023d0a472a0d2fc7907 100644
--- a/aleksis/core/assets/components/about/InstalledAppsList.vue
+++ b/aleksis/core/assets/components/about/InstalledAppsList.vue
@@ -1,5 +1,5 @@
 <template>
-  <ApolloQuery :query="require('./installedApps.graphql')">
+  <ApolloQuery :query="gqlInstalledApps">
     <template #default="{ result: { error, data }, isLoading }">
       <v-row v-if="isLoading">
         <v-col
@@ -31,6 +31,7 @@
 
 <script>
 import InstalledAppCard from "./InstalledAppCard.vue";
+import gqlInstalledApps from "./installedApps.graphql";
 
 export default {
   name: "InstalledAppsList",
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
index 06b28fc37f52f5440442815207d901fe42cdcd69..86cc128471428a50f474a28d38ea19925e9b6d04 100644
--- a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
@@ -86,13 +86,15 @@
 <script>
 import BackButton from "../BackButton.vue";
 import MessageBox from "../MessageBox.vue";
+import gqlCeleryProgress from "./celeryProgress.graphql";
+import gqlCeleryProgressFetched from "./celeryProgressFetched.graphql";
 
 export default {
   name: "CeleryProgress",
   components: { BackButton, MessageBox },
   apollo: {
     celeryProgressByTaskId: {
-      query: require("./celeryProgress.graphql"),
+      query: gqlCeleryProgress,
       variables() {
         return {
           taskId: this.$route.params.taskId,
@@ -114,7 +116,7 @@ export default {
       if (newState === "SUCCESS" || newState === "ERROR") {
         this.$apollo.queries.celeryProgressByTaskId.stopPolling();
         this.$apollo.mutate({
-          mutation: require("./celeryProgressFetched.graphql"),
+          mutation: gqlCeleryProgressFetched,
           variables: {
             taskId: this.$route.params.taskId,
           },
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
index 2bb95431cecd66bc68789bc1bfe7c5977b68cfb6..410e57fda0eca36d3acaca7fc0318174fd71d318 100644
--- a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
@@ -25,6 +25,7 @@
 
 <script>
 import TaskListItem from "./TaskListItem.vue";
+import gqlCeleryProgressButton from "./celeryProgressBottom.graphql";
 
 export default {
   name: "CeleryProgressBottom",
@@ -45,7 +46,7 @@ export default {
   },
   apollo: {
     celeryProgressByUser: {
-      query: require("./celeryProgressBottom.graphql"),
+      query: gqlCeleryProgressButton,
       pollInterval: 1000,
     },
   },
diff --git a/aleksis/core/assets/components/notifications/NotificationItem.vue b/aleksis/core/assets/components/notifications/NotificationItem.vue
index b1dea65c167df9417803a6e7c532309acce75325..6011765afe2ebf3df707d2dbfe7549e4f010d4a8 100644
--- a/aleksis/core/assets/components/notifications/NotificationItem.vue
+++ b/aleksis/core/assets/components/notifications/NotificationItem.vue
@@ -1,6 +1,6 @@
 <template>
   <ApolloMutation
-    :mutation="require('./markNotificationRead.graphql')"
+    :mutation="gqlmarkNotificationRead"
     :variables="{ id: this.notification.id }"
   >
     <template #default="{ mutate, loading, error }">
@@ -80,6 +80,8 @@
 </template>
 
 <script>
+import gqlMarkNotificationRead from "./markNotificationRead.graphql";
+
 export default {
   props: {
     notification: {
diff --git a/aleksis/core/assets/components/notifications/NotificationList.vue b/aleksis/core/assets/components/notifications/NotificationList.vue
index 7e16ee197f93ff3c4f68e6d1275e2a175272c64f..9ec08daa5048672241808b01be16223d8f8aba07 100644
--- a/aleksis/core/assets/components/notifications/NotificationList.vue
+++ b/aleksis/core/assets/components/notifications/NotificationList.vue
@@ -1,6 +1,6 @@
 <template>
   <ApolloQuery
-    :query="require('./myNotifications.graphql')"
+    :query="gqlMyNotifications"
     :poll-interval="1000"
   >
     <template #default="{ result: { error, data, loading } }">
@@ -73,6 +73,7 @@
 
 <script>
 import NotificationItem from "./NotificationItem.vue";
+import gqlMyNotifications from "./myNotifications.graphql";
 
 export default {
   components: {
diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js
index ea18ea4dbc7cd71e42a0062da2ab80a987b11c51..01f2ce2f2f8d314937536a8433c227d5633e5c51 100644
--- a/aleksis/core/assets/index.js
+++ b/aleksis/core/assets/index.js
@@ -3,9 +3,16 @@ import "@mdi/font/css/materialdesignicons.css";
 import "./util";
 import "./app";
 
+// This imports all known AlekSIS app entrypoints
+// The list is generated by util/frontent_helpers.py and passed to Vite,
+//  which aliases the app package names into virtual JavaScript modules
+//  and generates importing code at bundle time.
+import "aleksisAppImporter";
+
 import CeleryProgress from "./components/celery_progress/CeleryProgress.vue";
 import About from "./components/about/About.vue";
 
+
 window.router.addRoute({
   path: "/celery_progress/:taskId",
   component: CeleryProgress,
diff --git a/aleksis/core/management/commands/vite.py b/aleksis/core/management/commands/vite.py
new file mode 100644
index 0000000000000000000000000000000000000000..57370441b37a9db241a8a8a0bebbd8f8f00f5753
--- /dev/null
+++ b/aleksis/core/management/commands/vite.py
@@ -0,0 +1,29 @@
+import os
+
+from django.conf import settings
+
+from django_yarnpkg.management.base import BaseYarnCommand
+from django_yarnpkg.yarn import yarn_adapter
+
+from ...util.frontend_helpers import run_vite, write_vite_values
+
+
+class Command(BaseYarnCommand):
+    help = "Create Vite bundles for AlekSIS"  # noqa
+
+    def add_arguments(self, parser):
+        parser.add_argument("command", choices=["build", "serve"], nargs="?", default="build")
+        parser.add_argument("--no-install", action="store_true", default=False)
+
+    def handle(self, *args, **options):
+        super(Command, self).handle(*args, **options)
+
+        # Inject settings into Vite
+        write_vite_values(os.path.join(settings.NODE_MODULES_ROOT, "django-vite-values.json"))
+
+        # Install Node dependencies
+        if not options["no_install"]:
+            yarn_adapter.install(settings.YARN_INSTALLED_APPS)
+
+        # Run Vite build
+        run_vite([options["command"]])
diff --git a/aleksis/core/management/commands/webpack_bundle.py b/aleksis/core/management/commands/webpack_bundle.py
index ee38566a8cda8587c41aacf036eca1d7da16cd04..1b324ae200e993dba15119fb16335091217d7200 100644
--- a/aleksis/core/management/commands/webpack_bundle.py
+++ b/aleksis/core/management/commands/webpack_bundle.py
@@ -1,35 +1,16 @@
-import json
-import os
-import shutil
+import warnings
 
-from django.conf import settings
+from .vite import Command as ViteCommand
 
-from django_yarnpkg.management.base import BaseYarnCommand
-from django_yarnpkg.yarn import yarn_adapter
 
-from ...util.frontend_helpers import get_apps_with_assets
-
-
-class Command(BaseYarnCommand):
-    help = "Create webpack bundles for AlekSIS"  # noqa
+class Command(ViteCommand):
+    help = "Create Vite bundles for AlekSIS (legacy command alias)"  # noqa
 
     def handle(self, *args, **options):
-        super(Command, self).handle(*args, **options)
-
-        # Write webpack entrypoints for all apps
-        assets = {
-            app: {"dependOn": "core", "import": os.path.join(path, "index")}
-            for app, path in get_apps_with_assets().items()
-        }
-        assets["core"] = os.path.join(settings.BASE_DIR, "aleksis", "core", "assets", "index")
-        with open(os.path.join(settings.NODE_MODULES_ROOT, "webpack-entrypoints.json"), "w") as out:
-            json.dump(assets, out)
-
-        # Install Node dependencies
-        yarn_adapter.install(settings.YARN_INSTALLED_APPS)
+        warnings.warn(
+            "webpack_bundle is deprecated and will be removed "
+            "in AlekSIS-Core 4.0. Use the new vite command instead.",
+            UserWarning,
+        )
 
-        # Run webpack
-        config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "webpack.config.js")
-        shutil.copy(config_path, settings.NODE_MODULES_ROOT)
-        mode = "development" if settings.DEBUG else "production"
-        yarn_adapter.call_yarn(["run", "webpack", f"--mode={mode}"])
+        super().handle(*args, **options)
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index daafab787c5112d393e5e547e7ba0638149efd64..ceec09c7b38c2e0809b62d12948f894d0d6ced2f 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -97,7 +97,7 @@ INSTALLED_APPS = [
     "sass_processor",
     "django_any_js",
     "django_yarnpkg",
-    "webpack_loader",
+    "django_vite",
     "django_tables2",
     "maintenance_mode",
     "menu_generator",
@@ -572,22 +572,17 @@ YARN_INSTALLED_APPS = [
     "vue-apollo@^3.1.0",
     "vuetify@^2.6.7",
     "vue-router@^3.5.2",
-    "css-loader@^6.7.1",
-    "sass-loader@^13.0",
-    "vue-loader@^15.0.0",
-    "vue-style-loader@^4.1.3",
-    "vue-template-compiler@^2.7.7",
-    "webpack@^5.73.0",
-    "webpack-bundle-tracker@^1.6.0",
-    "webpack-cli@^4.10.0",
+    "vite@^4.0.1",
+    "@vitejs/plugin-vue2@^2.2.0",
+    "@rollup/plugin-node-resolve@^15.0.1",
+    "@rollup/plugin-graphql@^2.0.2",
+    "@rollup/plugin-virtual@^3.0.1",
     "vue-i18n@8",
     "eslint@^8.26.0",
     "eslint-plugin-vue@^9.7.0",
-    "eslint-webpack-plugin@^3.2.0",
     "eslint-config-prettier@^8.5.0",
     "stylelint@^14.14.0",
     "stylelint-config-standard@^29.0.0",
-    "stylelint-webpack-plugin@^3.3.0",
     "stylelint-config-prettier@^9.0.3",
 ]
 
@@ -596,17 +591,12 @@ merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True)
 JS_URL = _settings.get("js_assets.url", STATIC_URL)
 JS_ROOT = _settings.get("js_assets.root", os.path.join(NODE_MODULES_ROOT, "node_modules"))
 
-WEBPACK_LOADER = {
-    "DEFAULT": {
-        "CACHE": not DEBUG,
-        "STATS_FILE": os.path.join(NODE_MODULES_ROOT, "webpack-stats.json"),
-        "BUNDLE_DIR_NAME": "",
-        "POLL_INTERVAL": 0.1,
-        "IGNORE": [r".+\.hot-update.js", r".+\.map"],
-    }
-}
+DJANGO_VITE_ASSETS_PATH = os.path.join(NODE_MODULES_ROOT, "vite_bundles")
+DJANGO_VITE_DEV_MODE = DEBUG
+DJANGO_VITE_DEV_SERVER_PORT = 5173
+
 STATICFILES_DIRS = (
-    os.path.join(NODE_MODULES_ROOT, "webpack_bundles"),
+    DJANGO_VITE_ASSETS_PATH,
     JS_ROOT,
 )
 
@@ -755,6 +745,7 @@ if _settings.get("dev.uwsgi.celery", DEBUG):
     UWSGI.setdefault("attach-daemon", [])
     UWSGI["attach-daemon"].append(f"celery -A aleksis.core worker --concurrency={concurrency}")
     UWSGI["attach-daemon"].append("celery -A aleksis.core beat")
+    UWSGI["attach-daemon"].append("aleksis-admin vite --no-install serve")
 
 DEFAULT_FAVICON_PATHS = {
     "pwa_icon": os.path.join(STATIC_ROOT, "img/aleksis-icon-maskable.png"),
diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html
index f79c4a9bd7dfde9bcfcb5a2f822da5643054dca4..c1383da534105e30ccbb3ac09e67e35e1e80df15 100644
--- a/aleksis/core/templates/core/vue_base.html
+++ b/aleksis/core/templates/core/vue_base.html
@@ -1,7 +1,7 @@
 {# -*- engine:django -*- #}
 
 {% load i18n menu_generator static sass_tags any_js rules html_helpers %}
-{% load render_bundle from webpack_loader %}
+{% load django_vite %}
 {% get_current_language as LANGUAGE_CODE %}
 {% get_available_languages as LANGUAGES %}
 
@@ -53,6 +53,8 @@
   <script type="text/javascript" src="{% url 'config.js' %}"></script>
   {% include_js "iconify" %}
 
+  {% vite_hmr_client %}
+
   {% block extra_head %}{% endblock %}
 </head>
 <body {% if no_menu %}class="without-menu"{% endif %}>
@@ -209,7 +211,7 @@
 {{ request.site.preferences.theme__primary|json_script:"primary-color" }}
 {{ request.site.preferences.theme__secondary|json_script:"secondary-color" }}
 <script type="text/javascript" src="{% static 'js/search.js' %}"></script>
-{% render_bundle 'core' %}
+{% vite_asset 'aleksis/core/assets/index.js' %}
 {% block extra_body %}{% endblock %}
 </body>
 </html>
diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py
index 664707500bda7ffde9d5d8456be39128cf55ff8f..4389cdd6d50b6651f12e0d20d904849195a94156 100644
--- a/aleksis/core/util/frontend_helpers.py
+++ b/aleksis/core/util/frontend_helpers.py
@@ -1,7 +1,12 @@
+import json
 import os
+import shutil
+from typing import Any, Optional, Sequence
 
 from django.conf import settings
 
+from django_yarnpkg.yarn import yarn_adapter
+
 from .core_helpers import get_app_module, get_app_packages
 
 
@@ -17,6 +22,43 @@ def get_apps_with_assets():
     return assets
 
 
+def write_vite_values(out_path: str) -> dict[str, Any]:
+    vite_values = {
+        "static_url": settings.STATIC_URL,
+    }
+    # Write rollup entrypoints for all apps
+    vite_values["appEntrypoints"] = {}
+    for app, path in get_apps_with_assets().items():
+        ep = os.path.join(path, "index.js")
+        if os.path.exists(ep):
+            vite_values["appEntrypoints"][app] = ep
+    # Add core entrypoint
+    vite_values["coreEntrypoint"] = os.path.join(
+        settings.BASE_DIR, "aleksis", "core", "assets", "index.js"
+    )
+
+    with open(out_path, "w") as out:
+        json.dump(vite_values, out)
+
+
+def run_vite(args: Optional[Sequence[str]] = None) -> None:
+    args = list(args) if args else []
+
+    config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "vite.config.js")
+    shutil.copy(config_path, settings.NODE_MODULES_ROOT)
+
+    mode = "development" if settings.DEBUG else "production"
+    args += ["-m", mode]
+
+    log_level = settings.LOGGING["root"]["level"]
+    if settings.DEBUG or log_level == "DEBUG":
+        args.append("-d")
+    log_level = {"INFO": "info", "WARNING": "warn", "ERROR": "error"}.get(log_level, "silent")
+    args += ["-l", log_level]
+
+    yarn_adapter.call_yarn(["run", "vite"] + args)
+
+
 def get_language_cookie(code: str) -> str:
     """Build a cookie string to set a new language."""
     cookie_parts = [f"{settings.LANGUAGE_COOKIE_NAME}={code}"]
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..12a7bcc5baeec4f19645bc16bbfba556ace7f9f8
--- /dev/null
+++ b/aleksis/core/vite.config.js
@@ -0,0 +1,76 @@
+const fs = require("fs");
+const path = require("path");
+
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue2";
+import { nodeResolve } from "@rollup/plugin-node-resolve";
+import graphql from "@rollup/plugin-graphql";
+import virtual from "@rollup/plugin-virtual";
+
+const django_values = JSON.parse(fs.readFileSync("./django-vite-values.json"));
+
+function generateAppImporter(entrypoints) {
+  let code = "let appObjects = {};";
+  for (const appPackage of Object.keys(entrypoints)) {
+    let appName = appPackage.split(".").slice(-1)[0];
+    appName = appName.charAt(0).toUpperCase() + appName.substring(1);
+
+    code += `console.debug("Importing AlekSIS app entrypoint for ${appPackage}");\n`;
+    code += `import ${appName} from '${appPackage}';\n`;
+    code += `appObjects.push(${appName});\n`;
+  }
+  code += "export default appObjects;\n";
+  return code;
+}
+
+export default defineConfig({
+  root: path.resolve(".."),
+  base: django_values.static_url,
+  build: {
+    outDir: path.resolve("./vite_bundles/"),
+    manifest: true,
+    rollupOptions: {
+      input: django_values.coreEntrypoint,
+      output: {
+        manualChunks(id) {
+          // Split big libraries into own chunks
+          if (id.includes("node_modules/vue")) {
+            return "vue";
+          } else if (id.includes("node_modules/apollo")) {
+            return "apollo";
+          } else if (id.includes("node_modules/graphql")) {
+            return "graphql";
+          } else if (id.includes("node_modules")) {
+            // Fallback for all other libraries
+            return "vendor";
+          }
+
+          // Split each AlekSIS app in its own chunk
+          for (const [appPackage, ep] of Object.entries(django_values.appEntrypoints)) {
+            if (id.includes(ep)) {
+              return appPackage;
+            }
+          }
+        },
+      },
+    },
+  },
+  server: {
+    strictPort: true,
+    origin: "http://127.0.0.1:5173",
+  },
+  plugins: [
+    virtual({
+      aleksisAppImporter: generateAppImporter(django_values.appEntrypoints),
+    }),
+    vue(),
+    nodeResolve({ modulePaths: [path.resolve("./node_modules")] }),
+    graphql(),
+  ],
+  resolve: {
+    alias: {
+      vue: "vue/dist/vue.esm.js",
+      ...django_values.appEntrypoints,
+    },
+  },
+});
diff --git a/aleksis/core/webpack.config.js b/aleksis/core/webpack.config.js
deleted file mode 100644
index 8f327cbe55a6dba6b7ae5f104a89f4da46ddf711..0000000000000000000000000000000000000000
--- a/aleksis/core/webpack.config.js
+++ /dev/null
@@ -1,97 +0,0 @@
-const fs = require("fs");
-const path = require("path");
-const webpack = require("webpack");
-const BundleTracker = require("webpack-bundle-tracker");
-const { VueLoaderPlugin } = require("vue-loader");
-const ESLintPlugin = require("eslint-webpack-plugin");
-const StyleLintPlugin = require("stylelint-webpack-plugin");
-
-module.exports = {
-  context: __dirname,
-  entry: JSON.parse(fs.readFileSync("./webpack-entrypoints.json")),
-  output: {
-    path: path.resolve("./webpack_bundles/"),
-    filename: "[name]-[hash].js",
-    chunkFilename: "[id]-[chunkhash].js",
-  },
-  plugins: [
-    new BundleTracker({ filename: "./webpack-stats.json" }),
-    new VueLoaderPlugin(),
-    new ESLintPlugin({
-      extensions: ["js", "vue"],
-    }),
-    new StyleLintPlugin({
-      files: ["assets/**/*.{vue,htm,html,css,sss,less,scss,sass}"],
-    }),
-  ],
-  module: {
-    rules: [
-      {
-        test: /\.vue$/,
-        use: {
-          loader: "vue-loader",
-          options: {
-            transpileOptions: {
-              transforms: {
-                dangerousTaggedTemplateString: true,
-              },
-            },
-          },
-        },
-      },
-      {
-        test: /\.(css)$/,
-        use: ["vue-style-loader", "css-loader"],
-      },
-      {
-        test: /\.scss$/,
-        use: [
-          "vue-style-loader",
-          "css-loader",
-          {
-            loader: "sass-loader",
-            options: {
-              sassOptions: {
-                indentedSyntax: false,
-              },
-            },
-          },
-        ],
-      },
-      {
-        test: /\.(graphql|gql)$/,
-        exclude: /node_modules/,
-        loader: "graphql-tag/loader",
-      },
-    ],
-  },
-  optimization: {
-    runtimeChunk: "single",
-    splitChunks: {
-      chunks: "all",
-      maxInitialRequests: Infinity,
-      minSize: 0,
-      cacheGroups: {
-        vendor: {
-          test: /[\\/]node_modules[\\/]/,
-          name(module) {
-            // get the name. E.g. node_modules/packageName/not/this/part.js
-            // or node_modules/packageName
-            const packageName = module.context.match(
-              /[\\/]node_modules[\\/](.*?)([\\/]|$)/
-            )[1];
-
-            // npm package names are URL-safe, but some servers don't like @ symbols
-            return `npm.${packageName.replace("@", "")}`;
-          },
-        },
-      },
-    },
-  },
-  resolve: {
-    modules: [path.resolve("./node_modules")],
-    alias: {
-      vue$: "vue/dist/vue.esm.js",
-    },
-  },
-};
diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst
index ee46193d93282c9e44016b3b0ebf8e1f1b9c4b36..322d39023183a171b29f2fffc4a55988441a89c7 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -144,7 +144,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`:
 
 .. code-block:: shell
    pip3 install aleksis
-   aleksis-admin webpack_bundle
+   aleksis-admin vite build
    aleksis-admin collectstatic
    aleksis-admin migrate
    aleksis-admin createinitialrevisions
diff --git a/pyproject.toml b/pyproject.toml
index c9a5f25b70fabae18809434113aeed5cdef4a1d7..0ab9b866ddfe0cf79255cb41de68747973452c0b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -128,8 +128,8 @@ django-ical = "^1.8.3"
 django-iconify = "^0.3"
 customidenticon = "^0.1.5"
 graphene-django = "^3.0.0"
-django-webpack-loader = "^1.6.0"
 selenium = "^4.4.3"
+django-vite = "^2.0.2"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]
diff --git a/tox.ini b/tox.ini
index ff7a8c4aa3f0d97c2224ac9e4998fdaa246265c7..0c422a187839c40b718427898e2de2fab1c80758 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,7 +11,7 @@ skip_install = true
 envdir = {toxworkdir}/globalenv
 commands_pre =
      poetry install -E ldap
-     poetry run aleksis-admin webpack_bundle
+     poetry run aleksis-admin vite build
      poetry run aleksis-admin collectstatic --no-input
 commands =
     poetry run pytest --cov=. {posargs} aleksis/