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/aleksis/core/management/commands/webpack_bundle.py b/aleksis/core/management/commands/vite_bundle.py
similarity index 51%
rename from aleksis/core/management/commands/webpack_bundle.py
rename to aleksis/core/management/commands/vite_bundle.py
index ee38566a8cda8587c41aacf036eca1d7da16cd04..07beab7eac7db25c9c4c6f8f7156d7b3180edd40 100644
--- a/aleksis/core/management/commands/webpack_bundle.py
+++ b/aleksis/core/management/commands/vite_bundle.py
@@ -11,25 +11,29 @@ from ...util.frontend_helpers import get_apps_with_assets
 
 
 class Command(BaseYarnCommand):
-    help = "Create webpack bundles for AlekSIS"  # noqa
+    help = "Create Vite bundles for AlekSIS"  # 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")}
+        # Inject settings into Vite
+        vite_values = {
+            "static_url": settings.STATIC_URL,
+        }
+        # Write rollup entrypoints for all apps
+        vite_values["entrypoints"] = {
+            app: 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)
+        vite_values["entrypoints"]["core"] = os.path.join(settings.BASE_DIR, "aleksis", "core", "assets", "index")
+        with open(os.path.join(settings.NODE_MODULES_ROOT, "django-vite-values.json"), "w") as out:
+            json.dump(vite_values, out)
 
         # Install Node dependencies
         yarn_adapter.install(settings.YARN_INSTALLED_APPS)
 
-        # Run webpack
-        config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "webpack.config.js")
+        # Run Vite
+        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"
-        yarn_adapter.call_yarn(["run", "webpack", f"--mode={mode}"])
+        yarn_adapter.call_yarn(["run", "vite", "build", "-m", mode, "-l", "info", "-d"])
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index daafab787c5112d393e5e547e7ba0638149efd64..78bd718a01f76fb4c5e985f6f55ee31a5a2f12ff 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,16 @@ 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",
     "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 +590,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,
 )
 
diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html
index 261c3bb81e9fd24b33726afe369b239546e31faf..f741ef151be2ed6523fc8c6ee45a64b919484bf1 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 %}>
@@ -222,7 +224,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 'core' %}
 {% block extra_body %}{% endblock %}
 </body>
 </html>
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..b290bb1c7cd4158f89dfc4d1fde598725847c935
--- /dev/null
+++ b/aleksis/core/vite.config.js
@@ -0,0 +1,22 @@
+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';
+
+const django_values = JSON.parse(fs.readFileSync("./django-vite-values.json"));
+
+export default defineConfig({
+  base: django_values.static_url,
+  build: {
+    outDir: path.resolve("./vite_bundles/"),
+    manifest: true,
+    rollupOptions: {
+      input: django_values.entrypoints,
+      plugins: [nodeResolve({modulePaths: [path.resolve("./node_modules")]}), graphql()],
+    },
+  },
+  plugins: [vue()],
+});
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/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"]