diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000000000000000000000000000000000000..15b0fbe2891c2f685e867206fc6954620237b5d6
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,9 @@
+module.exports = {
+  extends: [
+    'plugin:vue/strongly-recommended',
+  ],
+  rules: {
+    'vue/no-unused-vars': 'off',
+    'vue/multi-word-component-names': 'off'
+  }
+}
diff --git a/.gitignore b/.gitignore
index 0faf3e4c3ecc7ff5de08a7c56ee313c488b183f6..dd85b53f78b12d78a2ff1053f4ab17e2601a62b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,9 +62,9 @@ docs/_build/
 *.aux
 
 # Generated files
-aleksis/node_modules/
-aleksis/static/
-aleksis/whoosh_index/
+/node_modules/
+/static/
+/whoosh_index/
 poetry.lock
 
 .coverage
@@ -74,8 +74,11 @@ htmlcov/
 maintenance_mode_state.txt
 media/
 package-lock.json
+yarn.lock
 
 # VSCode
 .vscode/
 .history/
 *.code-workspace
+
+/cache
diff --git a/.stylelintrc.json b/.stylelintrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..40db42c6689bd157e91cec65fda28693350b6332
--- /dev/null
+++ b/.stylelintrc.json
@@ -0,0 +1,3 @@
+{
+  "extends": "stylelint-config-standard"
+}
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 542508a839105541cd8921af698aee9468453668..ccdb00145e58d584b9cf8f23051a6e29881a36ed 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,11 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Added
+~~~~~
+
+* Introduce GraphQL API
+
 Fixed
 ~~~~~
 
diff --git a/Dockerfile b/Dockerfile
index 4864ac54613a8823fbc118fe73291e127b7939e1..df38046b1aff30d14a480603c80d5d2987cf4cdd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,8 @@
-FROM debian:bullseye-slim AS core
+FROM debian:bookworm-slim AS core
 
 # Build arguments
 ARG EXTRAS="ldap,s3,sentry"
-ARG APP_VERSION=""
+ARG APP_VERSION="==2.10.1.dev0+20220801181456.7ba74939"
 
 # Configure Python to be nice inside Docker and pip to stfu
 ENV PYTHONUNBUFFERED 1
@@ -15,6 +15,7 @@ ENV PIP_USE_DEPRECATED legacy-resolver
 ENV DEBIAN_FRONTEND noninteractive
 
 # Configure app settings for build and runtime
+ENV ALEKSIS_caching__dir /var/cache/aleksis
 ENV ALEKSIS_static__root /usr/share/aleksis/static
 ENV ALEKSIS_media__root /var/lib/aleksis/media
 ENV ALEKSIS_backup__location /var/lib/aleksis/backups
@@ -58,7 +59,8 @@ RUN   case ",$EXTRAS," in \
 
 # Install core
 RUN set -e; \
-    mkdir -p ${ALEKSIS_static__root} \
+    mkdir -p ${ALEKSIS_caching__dir} \
+             ${ALEKSIS_static__root} \
              ${ALEKSIS_media__root} \
              ${ALEKSIS_backup__location}; \
     eatmydata pip install AlekSIS-Core\[$EXTRAS\]$APP_VERSION
@@ -72,9 +74,12 @@ CMD ["/usr/local/bin/aleksis-docker-startup"]
 
 # Install assets
 FROM core as assets
-RUN eatmydata aleksis-admin yarn install; \
+RUN eatmydata aleksis-admin webpack_bundle; \
     eatmydata aleksis-admin collectstatic --no-input; \
     rm -rf /usr/local/share/.cache
+# FIXME Introduce deletion after we don't need materializecss anymore for SASS
+#  also in ONBUILD below
+#    rm -rf /usr/local/share/.cache ${ALEKSIS_caching__dir}/*
 
 # Clean up build dependencies
 FROM assets AS clean
@@ -118,7 +123,7 @@ ONBUILD RUN set -e; \
             if [ -n "$APPS" ]; then \
                 eatmydata pip install $APPS; \
             fi; \
-            eatmydata aleksis-admin yarn install; \
+            eatmydata aleksis-admin webpack_bundle; \
             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
new file mode 100644
index 0000000000000000000000000000000000000000..95196ba8a18303fcdf0da9fb1a77ff0e488ca55d
--- /dev/null
+++ b/aleksis/core/assets/app.js
@@ -0,0 +1,101 @@
+import Vue from "vue"
+import VueRouter from "vue-router"
+import Vuetify from "vuetify"
+import "vuetify/dist/vuetify.min.css"
+
+import ApolloClient from 'apollo-boost'
+import VueApollo from 'vue-apollo'
+
+Vue.use(Vuetify)
+Vue.use(VueRouter)
+
+const vuetify = new Vuetify({
+    // TODO: load theme data dynamically
+    //  - find a way to load template context data
+    //  - include all site preferences
+    //  - load menu stuff to render the sidenav
+    icons: {
+        iconfont: 'mdi', // default - only for display purposes
+        values: {
+          cancel: 'mdi-close-circle-outline',
+          delete: 'mdi-close-circle-outline',
+          success: 'mdi-check-circle-outline',
+          info: 'mdi-information-outline',
+          warning: 'mdi-alert-outline',
+          error: 'mdi-alert-octagon-outline',
+          prev: 'mdi-chevron-left',
+          next: 'mdi-chevron-right',
+          checkboxOn: 'mdi-checkbox-marked-outline',
+          checkboxIndeterminate: 'mdi-minus-box-outline',
+          edit: 'mdi-pencil-outline',
+        },
+    },
+    theme: {
+        dark: JSON.parse(document.getElementById("design-mode").textContent) === "dark",
+        themes: {
+            light: {
+                primary: JSON.parse(document.getElementById("primary-color").textContent),
+                secondary: JSON.parse(document.getElementById("secondary-color").textContent),
+            },
+            dark: {
+                primary: JSON.parse(document.getElementById("primary-color").textContent),
+                secondary: JSON.parse(document.getElementById("secondary-color").textContent),
+            },
+        },
+    },
+    lang: {
+        locales: JSON.parse(document.getElementById("language-info-list").textContent),
+        current: JSON.parse(document.getElementById("current-language").textContent),
+    }
+})
+
+const apolloClient = new ApolloClient({
+  uri: JSON.parse(document.getElementById("graphql-url").textContent)
+})
+
+import CacheNotification from "./components/CacheNotification.vue";
+import LanguageForm from "./components/LanguageForm.vue";
+import MessageBox from "./components/MessageBox.vue";
+import NotificationList from "./components/notifications/NotificationList.vue";
+import SidenavSearch from "./components/SidenavSearch.vue";
+
+Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it
+
+Vue.use(VueApollo)
+
+const apolloProvider = new VueApollo({
+  defaultClient: apolloClient,
+})
+
+const router = new VueRouter({
+  mode: "history",
+//  routes: [
+//    { path: "/", component: "TheApp" },
+//  }
+});
+
+const app = new Vue({
+    el: '#app',
+    apolloProvider,
+    vuetify: vuetify,
+    // delimiters: ["<%","%>"] // FIXME: discuss new delimiters, [[ <% [{ {[ <[ (( …
+    data: () => ({
+        drawer: vuetify.framework.breakpoint.lgAndUp,
+        group: null, // what does this mean?
+        urls: window.Urls,
+        django: window.django,
+        // FIXME: maybe just use window.django in every component or find a suitable way to access this property everywhere
+        showCacheAlert: false,
+        languageCode: JSON.parse(document.getElementById("current-language").textContent),
+    }),
+    components: {
+        "cache-notification": CacheNotification,
+        "language-form": LanguageForm,
+        "notification-list": NotificationList,
+        "sidenav-search": SidenavSearch,
+    },
+    router
+})
+
+window.app = app;
+window.router = router;
diff --git a/aleksis/core/assets/components/CacheNotification.vue b/aleksis/core/assets/components/CacheNotification.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4491615e6c7b4f3b42187c9a4ba9c0b66bbff4fd
--- /dev/null
+++ b/aleksis/core/assets/components/CacheNotification.vue
@@ -0,0 +1,25 @@
+<template>
+  <message-box :value="cache" type="warning">
+    {{ this.$root.django.gettext('This page may contain outdated information since there is no internet connection.') }}
+  </message-box>
+</template>
+
+<script>
+  export default {
+    name: "cache-notification",
+    data () {
+        return {
+            cache: false,
+        }
+    },
+    created() {
+        this.channel = new BroadcastChannel("cache-or-not");
+        this.channel.onmessage = (event) => {
+            this.cache = event.data === true;
+        }
+    },
+      destroyed(){
+        this.channel.close()
+      },
+  }
+</script>
diff --git a/aleksis/core/assets/components/LanguageForm.vue b/aleksis/core/assets/components/LanguageForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a19ae796e6e54105734a51e213000fd383249434
--- /dev/null
+++ b/aleksis/core/assets/components/LanguageForm.vue
@@ -0,0 +1,61 @@
+<template>
+  <form method="post" ref="form" :action="action" id="language-form">
+    <v-text-field
+      v-show="false"
+      name="csrfmiddlewaretoken"
+      :value="csrf_value"
+      type="hidden"
+    ></v-text-field>
+    <v-text-field
+      v-show="false"
+      name="next"
+      :value="next_url"
+      type="hidden"
+    ></v-text-field>
+    <input
+      name="language"
+      :value="current_language"
+      type="hidden"
+    >
+    <v-menu offset-y>
+      <template v-slot:activator="{ on, attrs }">
+        <v-btn
+          depressed
+          v-bind="attrs"
+          v-on="on"
+          color="primary"
+        >
+          <v-icon icon color="white">mdi-translate</v-icon>
+          {{ current_language }}
+        </v-btn>
+      </template>
+      <v-list id="language-dropdown" class="dropdown-content">
+        <v-list-item-group
+          v-model="current_language"
+          color="primary"
+        >
+          <v-list-item v-for="language in items" :key="language[0]" :value="language[0]" @click="submit(language[0])">
+            <v-list-item-title>{{ language[1] }}</v-list-item-title>
+          </v-list-item>
+        </v-list-item-group>
+      </v-list>
+    </v-menu>
+  </form>
+</template>
+
+<script>
+  export default {
+    data: () => ({
+        items: JSON.parse(document.getElementById("language-info-list").textContent),
+        current_language: JSON.parse(document.getElementById("current-language").textContent),
+    }),
+    methods: {
+        submit: function (language) {
+            this.current_language = language;
+            // this.$refs.form.submit()
+        },
+    },
+    props: ["action", "csrf_value", "next_url"],
+    name: "language-form",
+  }
+</script>
diff --git a/aleksis/core/assets/components/MessageBox.vue b/aleksis/core/assets/components/MessageBox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2a4cb17295835f5d3d1eb6f310a935eb4c2789be
--- /dev/null
+++ b/aleksis/core/assets/components/MessageBox.vue
@@ -0,0 +1,15 @@
+<script>
+  export default {
+    name: "message-box",
+    // Due to this component being a wrapper to a v-alert, all props of this can be used (and overridden).
+  }
+</script>
+
+<template>
+    <v-alert border="left" text v-bind="$attrs">
+      <slot>
+        Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+      </slot>
+    </v-alert>
+</template>
+
diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue
new file mode 100644
index 0000000000000000000000000000000000000000..30fdbe9596487061d962c82af968f659fa524e5f
--- /dev/null
+++ b/aleksis/core/assets/components/SidenavSearch.vue
@@ -0,0 +1,21 @@
+<script>
+  export default {
+    methods: {
+        submit: function () {
+            this.$refs.form.submit()
+        },
+    },
+    props: ["action", "placeholder"],
+    name: "sidenav-search",
+  }
+  // FIXME: implement suggestions etc, use "loading" attribute
+</script>
+
+<template>
+  <form method="get" ref="form" :action="action" id="search-form">
+    <v-text-field
+        :append-icon="'mdi-magnify'" @click:append="submit" single-line
+        id="search" name="q" type="search" enterkeyhint="search" :placeholder="placeholder"
+    ></v-text-field>
+  </form>
+</template>
diff --git a/aleksis/core/assets/components/notifications/NotificationItem.vue b/aleksis/core/assets/components/notifications/NotificationItem.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6c05b112e4afadb58f51ef71ac0c76451508415f
--- /dev/null
+++ b/aleksis/core/assets/components/notifications/NotificationItem.vue
@@ -0,0 +1,43 @@
+<template>
+  <ApolloMutation
+    :mutation="require('./markNotificationRead.graphql')"
+    :variables="{ id: this.notification.id }"
+  >
+    <template v-slot="{ mutate, loading, error }">
+      <v-list-item
+        v-intersect="mutate"
+      >
+        <v-list-item-content>
+          <v-list-item-title>{{ notification.title }}</v-list-item-title>
+
+          <v-list-item-subtitle>
+            <v-icon>mdi-clock-outline</v-icon>
+            {{ notification.created }}
+          </v-list-item-subtitle>
+
+          <v-list-item-subtitle>
+            {{ notification.description }}
+          </v-list-item-subtitle>
+        </v-list-item-content>
+
+        <v-list-item-action v-if="notification.link">
+          <v-btn text :href="notification.link">
+            {{ this.$root.django.gettext('More information →') }}
+          </v-btn>
+        </v-list-item-action>
+
+        <v-list-item-icon>
+          <v-chip color="primary">{{ notification.sender }}</v-chip>
+        </v-list-item-icon>
+      </v-list-item>
+    </template>
+  </ApolloMutation>
+</template>
+
+<script>
+  export default {
+    props: {
+      notification: Object,
+    },
+  }
+</script>
diff --git a/aleksis/core/assets/components/notifications/NotificationList.vue b/aleksis/core/assets/components/notifications/NotificationList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8428fd7115cafdbfa92b74c9c2655ed043b23de7
--- /dev/null
+++ b/aleksis/core/assets/components/notifications/NotificationList.vue
@@ -0,0 +1,27 @@
+<template>
+  <ApolloQuery
+    :query="require('./myNotifications.graphql')"
+    :pollInterval="1000"
+  >
+    <template v-slot="{ result: { error, data }, isLoading }">
+      <v-list two-line v-if="data && data.myNotifications.notifications">
+        <NotificationItem
+          v-for="notification in data.myNotifications.notifications"
+          :key="notification.id"
+          :notification="notification"
+        />
+      </v-list>
+      <p v-else>{{ this.$root.django.gettext('No notifications available yet.') }}</p>
+    </template>
+  </ApolloQuery>
+</template>
+
+<script>
+  import NotificationItem from "./NotificationItem.vue";
+
+  export default {
+    components: {
+      NotificationItem,
+    },
+  }
+</script>
diff --git a/aleksis/core/assets/components/notifications/markNotificationRead.graphql b/aleksis/core/assets/components/notifications/markNotificationRead.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..8cc7bed4325857b3407701900a356150d3614b68
--- /dev/null
+++ b/aleksis/core/assets/components/notifications/markNotificationRead.graphql
@@ -0,0 +1,8 @@
+mutation ($id: ID!) {
+  markNotificationRead(id: $id) {
+    notification {
+      id
+      read
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/notifications/myNotifications.graphql b/aleksis/core/assets/components/notifications/myNotifications.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..efa51f2c82e523ef2d5515e88536a0c6b08005ef
--- /dev/null
+++ b/aleksis/core/assets/components/notifications/myNotifications.graphql
@@ -0,0 +1,12 @@
+{
+  myNotifications: whoAmI {
+    notifications {
+      id
+      title
+      description
+      link
+      created
+      sender
+    }
+  }
+}
diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..94f4131baf7181ea2a299c518f5fe942b850926d
--- /dev/null
+++ b/aleksis/core/assets/index.js
@@ -0,0 +1,4 @@
+import '@mdi/font/css/materialdesignicons.css'
+
+import "./util"
+import "./app"
diff --git a/aleksis/core/assets/util.js b/aleksis/core/assets/util.js
new file mode 100644
index 0000000000000000000000000000000000000000..1b5041b216cdae06868440b14b88f3d69acee914
--- /dev/null
+++ b/aleksis/core/assets/util.js
@@ -0,0 +1,153 @@
+/*
+commented out to see if something breaks
+// Define maps between Python's strftime and Luxon's and Materialize's proprietary formats
+const pythonToMomentJs = {
+    "%a": "EEE",
+    "%A": "EEEE",
+    "%w": "E",
+    "%d": "dd",
+    "%b": "MMM",
+    "%B": "MMMM",
+    "%m": "MM",
+    "%y": "yy",
+    "%Y": "yyyy",
+    "%H": "HH",
+    "%I": "hh",
+    "%p": "a",
+    "%M": "mm",
+    "%s": "ss",
+    "%f": "SSSSSS",
+    "%z": "ZZZ",
+    "%Z": "z",
+    "%U": "WW",
+    "%j": "ooo",
+    "%W": "WW",
+    "%u": "E",
+    "%G": "kkkk",
+    "%V": "WW",
+};
+
+const pythonToMaterialize = {
+    "%d": "dd",
+    "%a": "ddd",
+    "%A": "dddd",
+    "%m": "mm",
+    "%b": "mmm",
+    "%B": "mmmm",
+    "%y": "yy",
+    "%Y": "yyyy",
+}
+
+function buildDateFormat(formatString, map) {
+    // Convert a Python strftime format string to another format string
+    for (const key in map) {
+        formatString = formatString.replace(key, map[key]);
+    }
+    return formatString;
+}
+
+function initDatePicker(sel) {
+    // Initialize datepicker [MAT]
+
+    // Get the date format from Django
+    const dateInputFormat = get_format('DATE_INPUT_FORMATS')[0]
+    const inputFormat = buildDateFormat(dateInputFormat, pythonToMomentJs);
+    const outputFormat = buildDateFormat(dateInputFormat, pythonToMaterialize);
+
+    const el = $(sel).datepicker({
+        format: outputFormat,
+        // Pull translations from Django helpers
+        i18n: {
+            months: calendarweek_i18n.month_names,
+            monthsShort: calendarweek_i18n.month_abbrs,
+            weekdays: calendarweek_i18n.day_names,
+            weekdaysShort: calendarweek_i18n.day_abbrs,
+            weekdaysAbbrev: calendarweek_i18n.day_abbrs.map(([v]) => v),
+
+            // Buttons
+            today: gettext('Today'),
+            cancel: gettext('Cancel'),
+            done: gettext('OK'),
+        },
+
+        // Set monday as first day of week
+        firstDay: get_format('FIRST_DAY_OF_WEEK'),
+        autoClose: true,
+        yearRange: [new Date().getFullYear() - 100, new Date().getFullYear() + 100],
+    });
+
+    // Set initial values of datepickers
+    $(sel).each(function () {
+        const currentValue = $(this).val();
+        if (currentValue) {
+            const currentDate = luxon.DateTime.fromFormat(currentValue, inputFormat).toJSDate();
+            $(this).datepicker('setDate', currentDate);
+        }
+    });
+
+    return el;
+}
+
+function initTimePicker(sel) {
+    // Initialize timepicker [MAT]
+    return $(sel).timepicker({
+        twelveHour: false,
+        autoClose: true,
+        i18n: {
+            cancel: 'Abbrechen',
+            clear: 'Löschen',
+            done: 'OK'
+        },
+    });
+}
+*/
+
+
+$(document).ready(function () {
+
+    // If JS is activated, the language form will be auto-submitted
+    $('.language-field select').change(function () {
+        $(this).parents(".language-form").submit();
+    });
+
+    // If auto-submit is activated (see above), the language submit must not be visible
+    $(".language-submit-p").hide();
+
+    // Initalize print button
+    $("#print").click(function () {
+        window.print();
+    });
+
+    // Sync color picker
+    $(".jscolor").change(function () {
+        $("#" + $(this).data("preview")).css("color", $(this).val());
+    });
+
+    // Initialise auto-completion for search bar
+    window.autocomplete = new Autocomplete({minimum_length: 2});
+    window.autocomplete.setup();
+
+    // Initialize text collapsibles [MAT, own work]
+    $(".text-collapsible").addClass("closed").removeClass("opened");
+
+    $(".text-collapsible .open-icon").click(function (e) {
+        var el = $(e.target).parent();
+        el.addClass("opened").removeClass("closed");
+    });
+    $(".text-collapsible .close-icon").click(function (e) {
+        var el = $(e.target).parent();
+        el.addClass("closed").removeClass("opened");
+    });
+
+    // Initialize the service worker
+    if ('serviceWorker' in navigator) {
+        console.debug("Start registration of service worker.");
+        navigator.serviceWorker.register('/serviceworker.js', {
+            scope: '/'
+        }).then(function () {
+            console.debug("Service worker has been registered.");
+        }).catch(function () {
+            console.debug("Service worker registration has failed.")
+        });
+    }
+});
diff --git a/aleksis/core/management/__init__.py b/aleksis/core/management/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/aleksis/core/management/commands/webpack_bundle.py b/aleksis/core/management/commands/webpack_bundle.py
new file mode 100644
index 0000000000000000000000000000000000000000..809a22c1beea482525601d16b46b1313ee6d03bd
--- /dev/null
+++ b/aleksis/core/management/commands/webpack_bundle.py
@@ -0,0 +1,34 @@
+import json
+import os
+import shutil
+
+from django.conf import settings
+
+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
+
+    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)
+
+        # Run webpack
+        config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "webpack.config.js")
+        shutil.copy(config_path, settings.NODE_MODULES_ROOT)
+        yarn_adapter.call_yarn(["run", "webpack"])
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index 49ddd4af743769252cbdbbae26d7de1d68c55776..e2f68d031aeb59dad329cc72c03e740142a0ba48 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -7,12 +7,14 @@ MENUS = {
             "name": _("Login"),
             "url": settings.LOGIN_URL,
             "svg_icon": "mdi:login-variant",
+            "vuetify_icon": "mdi-login-variant",
             "validators": ["menu_generator.validators.is_anonymous"],
         },
         {
             "name": _("Sign up"),
             "url": "account_signup",
             "svg_icon": "mdi:account-plus-outline",
+            "vuetify_icon": "mdi-account-plus-outline",
             "validators": [
                 "menu_generator.validators.is_anonymous",
                 ("aleksis.core.util.predicates.permission_validator", "core.can_register"),
@@ -22,6 +24,7 @@ MENUS = {
             "name": _("Accept invitation"),
             "url": "enter_invitation_code",
             "svg_icon": "mdi:key-outline",
+            "vuetify_icon": "mdi-key-outline",
             "validators": [
                 "menu_generator.validators.is_anonymous",
                 ("aleksis.core.util.predicates.permission_validator", "core.invite_enabled"),
@@ -31,6 +34,7 @@ MENUS = {
             "name": _("Dashboard"),
             "url": "index",
             "svg_icon": "mdi:home-outline",
+            "vuetify_icon": "mdi-home-outline",
             "validators": [
                 ("aleksis.core.util.predicates.permission_validator", "core.view_dashboard_rule")
             ],
@@ -39,6 +43,7 @@ MENUS = {
             "name": _("Admin"),
             "url": "#",
             "svg_icon": "mdi:security",
+            "vuetify_icon": "mdi-security",
             "validators": [
                 ("aleksis.core.util.predicates.permission_validator", "core.view_admin_menu"),
             ],
@@ -47,6 +52,7 @@ MENUS = {
                     "name": _("Announcements"),
                     "url": "announcements",
                     "svg_icon": "mdi:message-alert-outline",
+                    "vuetify_icon": "mdi-message-alert-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -58,6 +64,7 @@ MENUS = {
                     "name": _("School terms"),
                     "url": "school_terms",
                     "svg_icon": "mdi:calendar-range-outline",
+                    "vuetify_icon": "mdi-calendar-range-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -69,6 +76,7 @@ MENUS = {
                     "name": _("Dashboard widgets"),
                     "url": "dashboard_widgets",
                     "svg_icon": "mdi:view-dashboard-outline",
+                    "vuetify_icon": "mdi-view-dashboard-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -80,6 +88,7 @@ MENUS = {
                     "name": _("Data management"),
                     "url": "data_management",
                     "svg_icon": "mdi:chart-donut",
+                    "vuetify_icon": "mdi-chart-donut",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -91,6 +100,7 @@ MENUS = {
                     "name": _("System status"),
                     "url": "system_status",
                     "svg_icon": "mdi:power-settings",
+                    "vuetify_icon": "mdi-power-settings",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -102,6 +112,7 @@ MENUS = {
                     "name": _("Configuration"),
                     "url": "preferences_site",
                     "svg_icon": "mdi:tune",
+                    "vuetify_icon": "mdi-tune",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -113,12 +124,14 @@ MENUS = {
                     "name": _("Data checks"),
                     "url": "check_data",
                     "svg_icon": "mdi:list-status",
+                    "vuetify_icon": "mdi-list-status",
                     "validators": ["menu_generator.validators.is_superuser"],
                 },
                 {
                     "name": _("Manage permissions"),
                     "url": "manage_user_global_permissions",
                     "svg_icon": "mdi:shield-outline",
+                    "vuetify_icon": "mdi-shield-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -130,6 +143,7 @@ MENUS = {
                     "name": _("Backend Admin"),
                     "url": "admin:index",
                     "svg_icon": "mdi:database-cog-outline",
+                    "vuetify_icon": "mdi-database-cog-outline",
                     "validators": [
                         "menu_generator.validators.is_superuser",
                     ],
@@ -138,6 +152,7 @@ MENUS = {
                     "name": _("OAuth2 Applications"),
                     "url": "oauth2_applications",
                     "svg_icon": "mdi:gesture-tap-hold",
+                    "vuetify_icon": "mdi-gesture-tap-hold",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -151,6 +166,7 @@ MENUS = {
             "name": _("People"),
             "url": "#",
             "svg_icon": "mdi:account-group-outline",
+            "vuetify_icon": "mdi-account-group-outline",
             "root": True,
             "validators": [
                 ("aleksis.core.util.predicates.permission_validator", "core.view_people_menu_rule")
@@ -160,6 +176,7 @@ MENUS = {
                     "name": _("Persons"),
                     "url": "persons",
                     "svg_icon": "mdi:account-outline",
+                    "vuetify_icon": "mdi-account-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -171,6 +188,7 @@ MENUS = {
                     "name": _("Groups"),
                     "url": "groups",
                     "svg_icon": "mdi:account-multiple-outline",
+                    "vuetify_icon": "mdi-account-multiple-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -182,6 +200,7 @@ MENUS = {
                     "name": _("Group types"),
                     "url": "group_types",
                     "svg_icon": "mdi:shape-outline",
+                    "vuetify_icon": "mdi-shape-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -193,6 +212,7 @@ MENUS = {
                     "name": _("Groups and child groups"),
                     "url": "groups_child_groups",
                     "svg_icon": "mdi:account-multiple-plus-outline",
+                    "vuetify_icon": "mdi-account-multiple-plus-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -204,6 +224,7 @@ MENUS = {
                     "name": _("Additional fields"),
                     "url": "additional_fields",
                     "svg_icon": "mdi:palette-swatch-outline",
+                    "vuetify_icon": "mdi-palette-swatch-outline",
                     "validators": [
                         (
                             "aleksis.core.util.predicates.permission_validator",
@@ -215,6 +236,7 @@ MENUS = {
                     "name": _("Invite person"),
                     "url": "invite_person",
                     "svg_icon": "mdi:account-plus-outline",
+                    "vuetify_icon": "mdi-account-plus-outline",
                     "validators": [
                         "menu_generator.validators.is_authenticated",
                         ("aleksis.core.util.predicates.permission_validator", "core.can_invite"),
@@ -240,6 +262,7 @@ MENUS = {
             "name": _("Stop impersonation"),
             "url": "impersonate-stop",
             "svg_icon": "mdi:stop",
+            "vuetify_icon": "mdi-stop",
             "validators": [
                 "menu_generator.validators.is_authenticated",
                 "aleksis.core.util.core_helpers.is_impersonate",
@@ -249,6 +272,7 @@ MENUS = {
             "name": _("Account"),
             "url": "person",
             "svg_icon": "mdi:account-outline",
+            "vuetify_icon": "mdi-account-outline",
             "validators": [
                 "menu_generator.validators.is_authenticated",
                 "aleksis.core.util.core_helpers.has_person",
@@ -258,6 +282,7 @@ MENUS = {
             "name": _("Preferences"),
             "url": "preferences_person",
             "svg_icon": "mdi:cog-outline",
+            "vuetify_icon": "mdi-cog-outline",
             "validators": [
                 "menu_generator.validators.is_authenticated",
                 "aleksis.core.util.core_helpers.has_person",
@@ -267,6 +292,7 @@ MENUS = {
             "name": _("2FA"),
             "url": "two_factor:profile",
             "svg_icon": "mdi:two-factor-authentication",
+            "vuetify_icon": "mdi-two-factor-authentication",
             "validators": [
                 "menu_generator.validators.is_authenticated",
             ],
@@ -275,6 +301,7 @@ MENUS = {
             "name": _("Change password"),
             "url": "account_change_password",
             "svg_icon": "mdi:form-textbox-password",
+            "vuetify_icon": "mdi-form-textbox-password",
             "validators": [
                 "menu_generator.validators.is_authenticated",
                 (
@@ -287,6 +314,7 @@ MENUS = {
             "name": _("Third-party accounts"),
             "url": "socialaccount_connections",
             "svg_icon": "mdi:earth",
+            "vuetify_icon": "mdi-earth",
             "validators": [
                 "menu_generator.validators.is_authenticated",
                 "aleksis.core.util.core_helpers.has_person",
@@ -296,6 +324,7 @@ MENUS = {
             "name": _("Authorized applications"),
             "url": "oauth2_provider:authorized-token-list",
             "svg_icon": "mdi:gesture-tap-hold",
+            "vuetify_icon": "mdi-gesture-tap-hold",
             "validators": [
                 "menu_generator.validators.is_authenticated",
                 "aleksis.core.util.core_helpers.has_person",
@@ -305,6 +334,7 @@ MENUS = {
             "name": _("Calendar Feeds"),
             "url": "ical_feed_list",
             "svg_icon": "mdi:calendar-multiple",
+            "vuetify_icon": "mdi-calendar-multiple",
             "validators": [
                 "menu_generator.validators.is_authenticated",
                 (
@@ -318,6 +348,7 @@ MENUS = {
             "name": _("Logout"),
             "url": "logout",
             "svg_icon": "mdi:logout-variant",
+            "vuetify_icon": "mdi-logout-variant",
             "validators": ["menu_generator.validators.is_authenticated"],
         },
     ],
diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py
index 230e40686d0782151043723cef9edfffcf5a68b7..631a9337654db24782f823dd956c286855ca7792 100644
--- a/aleksis/core/preferences.py
+++ b/aleksis/core/preferences.py
@@ -198,6 +198,21 @@ class NotificationChannels(ChoicePreference):
     choices = get_notification_choices_lazy()
 
 
+@person_preferences_registry.register
+class Design(ChoicePreference):
+    """Change design (on supported pages)."""
+
+    section = theme
+    name = "design"
+    default = "light"
+    verbose_name = _("Select Design")
+    choices = [
+        # ("system", _("System Design")),
+        ("light", _("Light mode")),
+        # ("dark", _("Dark mode")),
+    ]
+
+
 @site_preferences_registry.register
 class PrimaryGroupPattern(StringPreference):
     """Regular expression to match primary group."""
diff --git a/aleksis/core/schema.py b/aleksis/core/schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e637aa20612a5d486732d85201c05febcfeed3b
--- /dev/null
+++ b/aleksis/core/schema.py
@@ -0,0 +1,110 @@
+import graphene
+from graphene_django import DjangoObjectType
+from graphene_django.forms.mutation import DjangoModelFormMutation
+
+from .forms import PersonForm
+from .models import Group, Notification, Person
+from .util.core_helpers import get_app_module, get_app_packages, has_person
+
+
+class NotificationType(DjangoObjectType):
+    class Meta:
+        model = Notification
+
+
+class PersonType(DjangoObjectType):
+    class Meta:
+        model = Person
+
+    full_name = graphene.Field(graphene.String)
+
+    def resolve_full_name(root: Person, info, **kwargs):
+        return root.full_name
+
+
+class GroupType(DjangoObjectType):
+    class Meta:
+        model = Group
+
+
+class PersonMutation(DjangoModelFormMutation):
+    person = graphene.Field(PersonType)
+
+    class Meta:
+        form_class = PersonForm
+
+
+class MarkNotificationReadMutation(graphene.Mutation):
+    class Arguments:
+        id = graphene.ID()  # noqa
+
+    notification = graphene.Field(NotificationType)
+
+    @classmethod
+    def mutate(cls, root, info, id):  # noqa
+        notification = Notification.objects.get(pk=id)
+        # FIXME permissions
+        notification.read = True
+        notification.save()
+
+        return notification
+
+
+class Query(graphene.ObjectType):
+    ping = graphene.String(default_value="pong")
+
+    notifications = graphene.List(NotificationType)
+
+    persons = graphene.List(PersonType)
+    person_by_id = graphene.Field(PersonType, id=graphene.ID())
+    who_am_i = graphene.Field(PersonType)
+
+    def resolve_notifications(root, info, **kwargs):
+        # FIXME do permission stuff
+        return Notification.objects.all()
+
+    def resolve_persons(root, info, **kwargs):
+        # FIXME do permission stuff
+        return Person.objects.all()
+
+    def resolve_person_by_id(root, info, id):  # noqa
+        return Person.objects.get(pk=id)
+
+    def resolve_who_am_i(root, info, **kwargs):
+        if has_person(info.context.user):
+            return info.context.user.person
+        else:
+            return None
+
+
+class Mutation(graphene.ObjectType):
+    update_person = PersonMutation.Field()
+
+    mark_notification_read = MarkNotificationReadMutation.Field()
+
+
+def build_global_schema():
+    """Build global GraphQL schema from all apps."""
+    query_bases = [Query]
+    mutation_bases = [Mutation]
+
+    for app in get_app_packages():
+        schema_mod = get_app_module(app, "schema")
+        if not schema_mod:
+            # The app does not define a schema
+            continue
+
+        if AppQuery := getattr(schema_mod, "Query", None):
+            query_bases.append(AppQuery)
+        if AppMutation := getattr(schema_mod, "Mutation", None):
+            mutation_bases.append(AppMutation)
+
+    # Define classes using all query/mutation classes as mixins
+    #  cf. https://docs.graphene-python.org/projects/django/en/latest/schema/#adding-to-the-schema
+    GlobalQuery = type("GlobalQuery", tuple(query_bases), {})
+    GlobalMutation = type("GlobalMutation", tuple(mutation_bases), {})
+
+    return graphene.Schema(query=GlobalQuery, mutation=GlobalMutation)
+
+
+schema = build_global_schema()
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 6ff73399849d267eeca7f37057d46ac5c6b0c843..3313076e8d2649bbbc0fa885e59ea9c5d84dcf62 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -43,7 +43,10 @@ _settings = LazySettings(
 )
 
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Cache directory for external operations
+CACHE_DIR = _settings.get("caching.dir", os.path.join(BASE_DIR, "cache"))
 
 SILENCED_SYSTEM_CHECKS = []
 
@@ -115,6 +118,7 @@ INSTALLED_APPS = [
     "sass_processor",
     "django_any_js",
     "django_yarnpkg",
+    "webpack_loader",
     "django_tables2",
     "maintenance_mode",
     "menu_generator",
@@ -155,6 +159,7 @@ INSTALLED_APPS = [
     "django_filters",
     "oauth2_provider",
     "rest_framework",
+    "graphene_django",
     "dj_iconify.apps.DjIconifyConfig",
 ]
 
@@ -164,7 +169,6 @@ INSTALLED_APPS += get_app_packages()
 STATICFILES_FINDERS = [
     "django.contrib.staticfiles.finders.FileSystemFinder",
     "django.contrib.staticfiles.finders.AppDirectoriesFinder",
-    "django_yarnpkg.finders.NodeModulesFinder",
     "sass_processor.finders.CssFinder",
 ]
 
@@ -427,6 +431,11 @@ REST_FRAMEWORK = {
     ]
 }
 
+# Configuration for GraphQL framework
+GRAPHENE = {
+    "SCHEMA": "aleksis.core.schema.schema",
+}
+
 # LDAP config
 if _settings.get("ldap.uri", None):
     # LDAP dependencies are not necessarily installed, so import them here
@@ -560,7 +569,7 @@ LOGOUT_REDIRECT_URL = "index"
 
 STATIC_ROOT = _settings.get("static.root", os.path.join(BASE_DIR, "static"))
 MEDIA_ROOT = _settings.get("media.root", os.path.join(BASE_DIR, "media"))
-NODE_MODULES_ROOT = _settings.get("node_modules.root", os.path.join(BASE_DIR, "node_modules"))
+NODE_MODULES_ROOT = CACHE_DIR
 
 YARN_INSTALLED_APPS = [
     "cleave.js@^1.6.0",
@@ -577,12 +586,45 @@ YARN_INSTALLED_APPS = [
     "luxon@^2.3.2",
     "@iconify/iconify@^2.2.1",
     "@iconify/json@^2.1.30",
+    "@mdi/font@^6.9.96",
+    "apollo-boost@^0.4.9",
+    "deepmerge@^4.2.2",
+    "graphql@^15.8.0",
+    "graphql-tag@^2.12.6",
+    "sass@~1.32",
+    "vue@^2.7.7",
+    "vue-apollo@^3.1.0",
+    "vuetify@^2.6.7",
+    "vue-router@^3.5.2",
+    "css-loader@^6.7.1",
+    "sass-loader@^8.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",
 ]
 
 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", NODE_MODULES_ROOT + "/node_modules")
+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"],
+    }
+}
+STATICFILES_DIRS = (
+    os.path.join(NODE_MODULES_ROOT, "webpack_bundles"),
+    JS_ROOT,
+)
+
 
 SELECT2_CSS = JS_URL + "/select2/dist/css/select2.min.css"
 SELECT2_JS = JS_URL + "/select2/dist/js/select2.min.js"
diff --git a/aleksis/core/static/public/vue_style.scss b/aleksis/core/static/public/vue_style.scss
new file mode 100644
index 0000000000000000000000000000000000000000..9c91c57ddf7fb4e56e69daa40c142bc2ed4b589b
--- /dev/null
+++ b/aleksis/core/static/public/vue_style.scss
@@ -0,0 +1,16 @@
+//////////////
+// HEADINGS //
+//////////////
+
+p, h1, h2, h3, h4, h5, h6, .card-title {
+  overflow-wrap: break-word;
+  hyphens: auto;
+}
+
+/////////////
+// HELPERS //
+/////////////
+
+[v-cloak] {
+  display: none;
+}
diff --git a/aleksis/core/templates/core/index.html b/aleksis/core/templates/core/index.html
index 766c88278ca688381356c35e79b3424b7fb5ce75..ed75f0512706299dda87b12f2a4ab9f8a613a595 100644
--- a/aleksis/core/templates/core/index.html
+++ b/aleksis/core/templates/core/index.html
@@ -19,21 +19,6 @@
     </div>
   {% endif %}
 
-  {% for notification in unread_notifications %}
-    <figure class="alert primary scale-transition">
-        <i class="material-icons left iconify" data-icon="mdi:information-outline"></i>
-
-        <div class="right">
-          <a class="btn-flat waves-effect" href="{% url "notification_mark_read" notification.id %}">
-            <i class="material-icons center iconify" data-icon="mdi:close"></i>
-          </a>
-        </div>
-
-        <figcaption>{{ notification.title }}</figcaption>
-        <p>{{ notification.description|linebreaks }}</p>
-    </figure>
-  {% endfor %}
-
   {% include "core/partials/announcements.html" with announcements=announcements %}
 
   <div class="row" id="live_load">
diff --git a/aleksis/core/templates/core/notifications.html b/aleksis/core/templates/core/notifications.html
index 60640670e6ca02ae788c4c37ff5f65d7c9969e2d..d7201867376e8618d08d9511dd94675a1bce1553 100644
--- a/aleksis/core/templates/core/notifications.html
+++ b/aleksis/core/templates/core/notifications.html
@@ -1,32 +1,9 @@
-{% extends 'core/base.html' %}
+{% extends 'core/vue_base.html' %}
 {% load i18n static dashboard %}
 
 {% block browser_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %}
 {% block page_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %}
 
-
 {% block content %}
-  {% if object_list %}
-    <ul class="collection">
-      {% for notification in object_list %}
-        <li class="collection-item">
-          <span class="badge new primary-color">{{ notification.sender }}</span>
-          <span class="title">{{ notification.title }}</span>
-          <p>
-            <i class="material-icons iconify left" data-icon="mdi:clock-outline"></i> {{ notification.created }}
-          </p>
-          <p>
-            {{ notification.description|linebreaks }}
-          </p>
-          {% if notification.link %}
-            <p>
-              <a href="{{ notification.link }}">{% blocktrans %}More information →{% endblocktrans %}</a>
-            </p>
-          {% endif %}
-        </li>
-      {% endfor %}
-    </ul>
-  {% else %}
-    <p>{% blocktrans %}No notifications available yet.{% endblocktrans %}</p>
-  {% endif %}
+  <notification-list/>
 {% endblock %}
diff --git a/aleksis/core/templates/core/partials/vue_avatar_content.html b/aleksis/core/templates/core/partials/vue_avatar_content.html
new file mode 100644
index 0000000000000000000000000000000000000000..c94a0734fa28383a78bcb6f082347ddbb4f77826
--- /dev/null
+++ b/aleksis/core/templates/core/partials/vue_avatar_content.html
@@ -0,0 +1,25 @@
+{% load rules i18n %}
+{% has_perm 'core.view_avatar_rule' request.user person_or_user as can_view_avatar %}
+{% has_perm 'core.view_photo_rule' request.user person_or_user as can_view_photo %}
+{% if SITE_PREFERENCES.account__person_prefer_photo and person_or_user.photo and can_view_photo %}
+  <img src="{{ person_or_user.photo.url }}"
+       alt="{{ person_or_user.full_name }}" {% if title %} title="{{ person_or_user.full_name }}"{% endif %}/>
+{% elif person_or_user.identicon_url %}
+
+  {# If this is a person #}
+  {% if can_view_avatar %}
+    <img src="{{ person_or_user.avatar_url }}"
+         alt="{{ person_or_user.full_name }} ({% trans "Avatar" %})" {% if title %}
+         title="{{ person_or_user.full_name }} ({% trans "Avatar" %})"{% endif %}/>
+  {% else %}
+    <img src="{{ person_or_user.identicon_url }}"
+         alt="{{ person_or_user.full_name }} ({% trans "Identicon" %})" {% if title %}
+         title="{{ person_or_user.full_name }} ({% trans "Identicon" %})"{% endif %} />
+  {% endif %}
+
+{% else %}
+  {# There is a user without a person #}
+  <v-icon>
+    <i class="material-icons">person</i>
+  </v-icon>
+{% endif %}
diff --git a/aleksis/core/templates/core/partials/vue_footer_menu.html b/aleksis/core/templates/core/partials/vue_footer_menu.html
new file mode 100644
index 0000000000000000000000000000000000000000..c751f8cdc9aa64b7763e11c63d40a3232ffd4706
--- /dev/null
+++ b/aleksis/core/templates/core/partials/vue_footer_menu.html
@@ -0,0 +1,10 @@
+{# -*- engine:django -*- #}
+
+{% for item in FOOTER_MENU.items.all %}
+  <v-btn plain tile href="{{ item.url }}" color="primary">
+    {% if item.icon %}
+      <v-icon left>mdi-{{ item.icon }}</v-icon>
+    {% endif %}
+    {{ item.name }}
+  </v-btn>
+{% endfor %}
diff --git a/aleksis/core/templates/core/partials/vue_language_form.html b/aleksis/core/templates/core/partials/vue_language_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..a1a7953599b1fc652a25c7eaa6870106c8d2852f
--- /dev/null
+++ b/aleksis/core/templates/core/partials/vue_language_form.html
@@ -0,0 +1,36 @@
+{# -*- engine:django -*- #}
+
+{% load i18n %}
+
+
+<form action="{% url 'set_language' %}" method="post" class="language-form">
+  {% csrf_token %}
+  <input name="next" type="hidden" value="{{ request.get_full_path }}">
+
+  {% get_current_language as LANGUAGE_CODE %}
+  {% get_language_info_list for request.site.preferences.internationalisation__languages as languages %}
+
+  {# Select #}
+  <!--<div class="input-field language-field">
+    <span>{% trans "Language" %}</span>
+    <select name="language">
+      {% for language in languages %}
+        <option value="{{ language.code }}" {% if language.code == LANGUAGE_CODE %}
+                selected {% endif %}>{{ language.name_local }}</option>
+      {% endfor %}
+    </select>
+  </div>-->
+  <v-select
+    :items="items"
+    label="{% trans "Language" %}"
+    color="white"
+    class="white--text"
+  ></v-select>
+
+  {# Submit button (only visible if JS isn't activated #}
+  <p class="language-submit-p">
+    <button type="submit" class="btn-flat waves-effect waves-light white-text">
+      {% trans "Select language" %}
+    </button>
+  </p>
+</form>
diff --git a/aleksis/core/templates/core/partials/vue_no_person.html b/aleksis/core/templates/core/partials/vue_no_person.html
new file mode 100644
index 0000000000000000000000000000000000000000..f49093686e8fae83f6af3a0233e42650bd8bbd26
--- /dev/null
+++ b/aleksis/core/templates/core/partials/vue_no_person.html
@@ -0,0 +1,21 @@
+{# -*- engine:django -*- #}
+
+{% load i18n %}
+
+{% if user.person.is_dummy or not user.person and not user.is_anonymous %}
+  <message-box type="error">
+
+  {% if user.person.is_dummy %}
+    {% blocktrans %}
+      Your administrator account is not linked to any person. Therefore,
+      a dummy person has been linked to your account.
+    {% endblocktrans %}
+  {% else %}
+    {% blocktrans %}
+      Your user account is not linked to a person. This means you
+      cannot access any school-related information. Please contact
+      the managers of AlekSIS at your school.
+    {% endblocktrans %}
+  {% endif %}
+  </message-box>
+{% endif %}
diff --git a/aleksis/core/templates/core/partials/vue_sidenav.html b/aleksis/core/templates/core/partials/vue_sidenav.html
new file mode 100644
index 0000000000000000000000000000000000000000..a9701b689c156f7618edd68c7c54f89ea2106a16
--- /dev/null
+++ b/aleksis/core/templates/core/partials/vue_sidenav.html
@@ -0,0 +1,64 @@
+{# -*- engine:django -*- #}
+
+{% load menu_generator data_helpers %}
+
+{% get_menu "NAV_MENU_CORE" as core_menu %}
+
+{% for item in core_menu %}
+  {% if not item.submenu %}
+    <v-list-item {% if item.selected %} input-value="true" {% endif %}
+      {% if item.new_tab %} target="_blank" {% endif %} href="{{ item.url }}">
+      <v-list-item-icon>
+        {% if item.vuetify_icon %}
+          <v-icon>{{ item.vuetify_icon }}</v-icon>
+        {% elif item.icon_class %}
+          <i class="{{ item.icon_class }}"></i>
+        {% elif item.icon %}
+          <i class="material-icons">{{ item.icon }}</i>
+        {% elif item.svg_icon %}
+          <i class="material-icons iconify" data-icon="{{ item.svg_icon }}"></i>
+        {% endif %}
+      </v-list-item-icon>
+      <v-list-item-title>{{ item.name }}</v-list-item-title>
+      {% build_badge item as badge %}
+      {% if badge %}
+        <span class="new badge sidenav-badge"> {{ badge }}</span>
+      {% endif %}
+    </v-list-item>
+  {% endif %}
+  {% if item.submenu %}
+    <v-list-group {% if item.selected %} value="true" {% endif %}
+      {% if item.new_tab %} target="_blank" {% endif %} href="{{ item.url|default:"#" }}"
+                  {% if item.vuetify_icon %}prepend-icon="{{ item.vuetify_icon }}"{% endif %}
+    >
+      <template v-slot:activator>
+        <v-list-item-title>{{ item.name }}</v-list-item-title>
+      </template>
+      {% build_badge item as badge %}
+      {% if badge %}
+        <span class="new badge sidenav-badge"> {{ badge }}</span>
+      {% endif %}
+      {% for menu in item.submenu %}
+        <v-list-item {% if menu.selected %} input-value="true" {% endif %} href="{{ menu.url }}">
+          <v-list-item-icon>
+            {% if menu.vuetify_icon %}
+              <v-icon>{{ menu.vuetify_icon }}</v-icon>
+            {% elif menu.icon_class %}
+              <i class="{{ menu.icon_class }}"></i>
+            {% elif menu.icon %}
+              <i class="material-icons">{{ menu.icon }}</i>
+            {% elif menu.svg_icon %}
+              <i class="material-icons iconify" data-icon="{{ menu.svg_icon }}"></i>
+            {% endif %}
+          </v-list-item-icon>
+          <v-list-item-title>{{ menu.name }}</v-list-item-title>
+          {% build_badge menu as badge %}
+          {% if badge %}
+            <span class="new badge sidenav-badge"> {{ badge }}</span>
+          {% endif %}
+          </a>
+        </v-list-item>
+      {% endfor %}
+    </v-list-group>
+  {% endif %}
+{% endfor %}
diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html
new file mode 100644
index 0000000000000000000000000000000000000000..22ea3d3a3ab3267c25ded2161d7702e1ca45f220
--- /dev/null
+++ b/aleksis/core/templates/core/vue_base.html
@@ -0,0 +1,222 @@
+{# -*- 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 %}
+
+
+<!DOCTYPE html>
+<html lang="{{ LANGUAGE_CODE }}">
+<head>
+  {% include "core/partials/meta.html" %}
+
+  <title>
+    {% block no_browser_title %}
+      {% block browser_title %}{% endblock %} —
+    {% endblock %}
+    {{ request.site.preferences.general__title }}
+  </title>
+
+  {# CSS #}
+  {# FIXME ↓ #}
+  {#  {% include_css "material-design-icons" %}#}
+  {% include_css "Roboto100" %}
+  {% include_css "Roboto300" %}
+  {% include_css "Roboto400" %}
+  {% include_css "Roboto500" %}
+  {% include_css "Roboto700" %}
+  {% include_css "Roboto900" %}
+  {#  <link rel="stylesheet" href="{% sass_src 'public/style.scss' %}">#}
+
+  <!-- FIXME: Find a way to use SCSS!!! -->
+
+  {# Add JS URL resolver #}
+  <script src="{% url "js_reverse" %}" type="text/javascript"></script>
+
+  {# Add i18n names for calendar (for use in datepicker) #}
+  {# Passing the locale is not necessary for the scripts to work, but prevents caching issues #}
+  <script src="{% url "javascript-catalog" %}?locale={{ LANGUAGE_CODE }}" type="text/javascript"></script>
+  <script src="{% url "calendarweek_i18n_js" %}?first_day=6&amp;locale={{ LANGUAGE_CODE }}"
+          type="text/javascript"></script>
+
+  {% if SENTRY_ENABLED %}
+    {% if SENTRY_TRACE_ID %}
+      <meta name="sentry-trace" content="{{ SENTRY_TRACE_ID }}"/>
+    {% endif %}
+    {% include_js "Sentry" %}
+    {{ SENTRY_SETTINGS|json_script:"sentry_settings" }}
+    <script type="text/javascript">
+      const sentry_settings = JSON.parse(document.getElementById('sentry_settings').textContent);
+
+      Sentry.init({
+        dsn: sentry_settings.dsn,
+        environment: sentry_settings.environment,
+        tracesSampleRate: sentry_settings.traces_sample_rate,
+        integrations: [new Sentry.Integrations.BrowserTracing()]
+      });
+    </script>
+  {% endif %}
+
+  <script type="text/javascript" src="{% url 'config.js' %}"></script>
+  {% include_js "iconify" %}
+
+  {# Include jQuery early to provide $(document).ready #}
+  {% include_js "jQuery" %}
+
+  {% block extra_head %}{% endblock %}
+</head>
+<body {% if no_menu %}class="without-menu"{% endif %}>
+<main id="app">
+  <v-app v-cloak>
+    {% if not no_menu %}
+      <v-navigation-drawer app v-model="drawer">
+        <v-list nav dense shaped>
+          <v-list-item class="logo">
+            {% static "img/aleksis-banner.svg" as aleksis_banner %}
+            <a id="logo-container" href="/" class="brand-logo">
+              <img src="{% firstof request.site.preferences.theme__logo.url aleksis_banner %}"
+                   alt="{{ request.site.preferences.general__title }} – Logo" style="width: 100%">
+            </a>
+          </v-list-item>
+          {% has_perm 'core.search_rule' user as search %}
+          {% if search %}
+            <v-list-item class="search">
+              <sidenav-search action="{% url "haystack_search" %}" placeholder="{% trans "Search" %}"></sidenav-search>
+            </v-list-item>
+          {% endif %}
+          {% include "core/partials/vue_sidenav.html" %}
+        </v-list>
+
+      </v-navigation-drawer>
+    {% endif %}
+    <v-app-bar app color="primary white--text">
+      <v-app-bar-nav-icon @click="drawer = !drawer" color="white"></v-app-bar-nav-icon>
+
+      <v-toolbar-title tag="a" class="white--text text-decoration-none" href="{% url "index" %}">
+        {{ request.site.preferences.general__title }}
+      </v-toolbar-title>
+
+      <v-spacer></v-spacer>
+      <language-form action="{% url "set_language" %}" csrf_value={{ csrf_token }} next_url={{ request.get_full_path }}></language-form>
+      {% if user.is_authenticated %}
+        <v-menu offset-y>
+          <template v-slot:activator="{ on, attrs }">
+            <v-btn depressed color="primary" v-bind="attrs" v-on="on">
+              <v-icon color="white">
+                {% if request.user.person.unread_notifications_count > 0 %}
+                  mdi-bell-badge-outline
+                {% else %}
+                  mdi-bell-outline
+                {% endif %}
+              </v-icon>
+            </v-btn>
+          </template>
+          <notification-list/>
+        </v-menu>
+        <v-menu offset-y>
+          <template v-slot:activator="{ on, attrs }">
+            <v-avatar v-bind="attrs" v-on="on">
+              {% include "core/partials/vue_avatar_content.html" with person_or_user=request.user.person %}
+            </v-avatar>
+          </template>
+          {% get_menu "NAVBAR_ACCOUNT_MENU" as account_menu %}
+          <v-list>
+            <v-subheader>REPORTS</v-subheader>
+            {% for item in account_menu %}
+              {% if item.divider %}
+                <v-divider></v-divider>
+              {% endif %}
+              <v-list-item href="{{ item.url }}">
+                <v-list-item-icon>
+                  {% if item.vuetify_icon %}
+                    <v-icon>{{ item.vuetify_icon }}</v-icon>
+                  {% elif item.icon_class %}
+                    <i class="{{ item.icon_class }}"></i>
+                  {% elif item.icon %}
+                    <i class="material-icons">{{ item.icon }}</i>
+                  {% elif item.svg_icon %}
+                    <i class="material-icons iconify" data-icon="{{ item.svg_icon }}"></i>
+                  {% endif %}
+                </v-list-item-icon>
+                <v-list-item-title>{{ item.name }}</v-list-item-title>
+                </a>
+              </v-list-item>
+            {% endfor %}
+          </v-list>
+        </v-menu>
+      {% else %}
+        <v-btn icon href="{% url "notifications" %}">
+          <v-icon>mdi-bell-outline</v-icon>
+        </v-btn>
+      {% endif %}
+    </v-app-bar>
+    <v-main>
+      <v-container>
+        <cache-notification></cache-notification>
+        {% include 'core/partials/vue_no_person.html' %}
+
+        {% if messages %}
+          {% for message in messages %}
+            <message-box type="{{ message.tags }}">{{ message }}</message-box>
+          {% endfor %}
+        {% endif %}
+        {% block no_page_title %}
+          <h1 class="text-h2">{% block page_title %}{% endblock %}</h1>
+        {% endblock %}
+        {% block content %}{% endblock %}
+      </v-container>
+    </v-main>
+
+    <v-footer app absolute inset class="white--text">
+      <v-container>
+        <v-row>
+          <v-col cols="12" lg="6">
+            {% include "core/partials/vue_footer_menu.html" %}
+          </v-col>
+          <v-col cols="12" >
+            <v-row>
+              <v-btn plain rounded href="{% url "about_aleksis" %}" color="white">
+                {% trans "About AlekSIS® — The Free School Information System" %}
+              </v-btn>
+              <span>© The AlekSIS Team</span>
+              <v-spacer></v-spacer>
+              {% if request.site.preferences.footer__imprint_url %}
+                <v-btn plain rounded href="{{ request.site.preferences.footer__imprint_url }}" color="white">
+                  {% trans "Imprint" %}
+                </v-btn>
+              {% endif %}
+              {% if request.site.preferences.footer__privacy_url and request.site.preferences.footer__imprint_url %}
+                ·
+              {% endif %}
+              {% if request.site.preferences.footer__privacy_url %}
+                <v-btn plain rounded href="{{ request.site.preferences.footer__privacy_url }}" color="white">
+                  {% trans "Privacy Policy" %}
+                </v-btn>
+              {% endif %}
+            </v-row>
+          </v-col>
+        </v-row>
+      </v-container>
+    </v-footer>
+  </v-app>
+</main>
+
+
+{% include_js "luxon" %}
+{#{% include_js "materialize" %}#}
+{% 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" }}
+<script type="text/javascript" src="{% static 'js/search.js' %}"></script>
+{% render_bundle 'core' %}
+{% block extra_body %}{% endblock %}
+</body>
+</html>
diff --git a/aleksis/core/templates/core/vue_dummy.html b/aleksis/core/templates/core/vue_dummy.html
new file mode 100644
index 0000000000000000000000000000000000000000..c5ae6f471542d90828a50e00463e80d9167985ba
--- /dev/null
+++ b/aleksis/core/templates/core/vue_dummy.html
@@ -0,0 +1,9 @@
+{% extends 'core/vue_base.html' %}
+{% load i18n static dashboard %}
+
+{% block browser_title %}{% blocktrans %}Dummy page{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Dummy page{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <p>Nothing here</p>
+{% endblock %}
diff --git a/aleksis/core/templatetags/html_helpers.py b/aleksis/core/templatetags/html_helpers.py
index 34066192527930c2a4096f49deb3d247f883c31e..8ca5be151d2edc39bf6787e9e51eabb3350714a0 100644
--- a/aleksis/core/templatetags/html_helpers.py
+++ b/aleksis/core/templatetags/html_helpers.py
@@ -2,6 +2,7 @@ import random
 import string
 
 from django import template
+from django.shortcuts import reverse
 
 from bs4 import BeautifulSoup
 
@@ -40,3 +41,9 @@ def generate_random_id(prefix: str, length: int = 10) -> str:
     return prefix + "".join(
         random.choice(string.ascii_lowercase) for i in range(length)  # noqa: S311
     )
+
+
+@register.simple_tag(takes_context=True)
+def absolute_url(context, view_name, *args, **kwargs):
+    request = context["request"]
+    return request.build_absolute_uri(reverse(view_name, args=args, kwargs=kwargs))
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index e76da89d18ed2078fc2405db11829aa344725fdf..c441d2ab8430d26faf539dbcbf4d69b5824196d5 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -3,12 +3,14 @@ from django.conf import settings
 from django.contrib import admin
 from django.contrib.auth import views as auth_views
 from django.urls import include, path
+from django.views.decorators.csrf import csrf_exempt
 from django.views.i18n import JavaScriptCatalog
 
 import calendarweek.django
 import debug_toolbar
 from ckeditor_uploader import views as ckeditor_uploader_views
 from django_js_reverse.views import urls_js
+from graphene_django.views import GraphQLView
 from health_check.urls import urlpatterns as health_urls
 from oauth2_provider.views import ConnectDiscoveryInfoView
 from rules.contrib.views import permission_required
@@ -21,6 +23,7 @@ urlpatterns = [
     path(settings.MEDIA_URL.removeprefix("/"), include("titofisto.urls")),
     path("manifest.json", views.ManifestView.as_view(), name="manifest"),
     path("serviceworker.js", views.ServiceWorkerView.as_view(), name="service_worker"),
+    path("vue_dummy/", views.vue_dummy, name="vue_dummy"),
     path("offline/", views.OfflineView.as_view(), name="offline"),
     path("about/", views.about, name="about_aleksis"),
     path("accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup"),
@@ -95,11 +98,6 @@ urlpatterns = [
     path("", views.index, name="index"),
     path("notifications/", views.NotificationsListView.as_view(), name="notifications"),
     path("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"),
-    path(
-        "notifications/mark-read/<int:id_>",
-        views.notification_mark_read,
-        name="notification_mark_read",
-    ),
     path("groups/group_type/create", views.edit_group_type, name="create_group_type"),
     path(
         "groups/group_type/<int:id_>/delete",
@@ -146,6 +144,7 @@ urlpatterns = [
         name="oauth2_provider:authorize",
     ),
     path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")),
+    path("graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True)), name="graphql"),
     path("__i18n__/", include("django.conf.urls.i18n")),
     path(
         "ckeditor/upload/",
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index 67adbf943886d5e503a9009e56b8eeb7f1ed823c..45a2253613b74d97a59c7b7711ae8c5ab31af444 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -72,18 +72,18 @@ def get_app_packages(only_official: bool = False) -> Sequence[str]:
     return apps
 
 
-def get_app_settings_module(app: str) -> Optional[ModuleType]:
-    """Get the settings module of an app."""
+def get_app_module(app: str, name: str) -> Optional[ModuleType]:
+    """Get a named module of an app."""
     pkg = ".".join(app.split(".")[:-2])
-    mod_settings = None
+
     while "." in pkg:
         try:
-            return import_module(pkg + ".settings")
+            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
 
 
@@ -100,7 +100,7 @@ def merge_app_settings(
     potentially malicious apps!
     """
     for app in get_app_packages():
-        mod_settings = get_app_settings_module(app)
+        mod_settings = get_app_module(app, "settings")
         if not mod_settings:
             continue
 
@@ -131,7 +131,7 @@ def get_app_settings_overrides() -> dict[str, Any]:
     overrides = {}
 
     for app in get_app_packages(True):
-        mod_settings = get_app_settings_module(app)
+        mod_settings = get_app_module(app, "settings")
         if not mod_settings:
             continue
 
diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..034b1a0be59bb32c5caaa2917e09d71f213d392f
--- /dev/null
+++ b/aleksis/core/util/frontend_helpers.py
@@ -0,0 +1,15 @@
+import os
+
+from .core_helpers import get_app_module, get_app_packages
+
+
+def get_apps_with_assets():
+    """Get a dictionary of apps that ship frontend assets."""
+    assets = {}
+    for app in get_app_packages():
+        mod = get_app_module(app, "apps")
+        path = os.path.join(os.path.dirname(mod.__file__), "assets")
+        if os.path.isdir(path):
+            package = ".".join(app.split(".")[:-2])
+            assets[package] = path
+    return assets
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index ea910292f63c7e1fdba3f7144b4128641d2b1446..01350da6482743593964dfa52f3e44ae695e1e20 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -99,7 +99,6 @@ from .models import (
     DummyPerson,
     Group,
     GroupType,
-    Notification,
     OAuthApplication,
     PDFFile,
     Person,
@@ -550,18 +549,9 @@ class TestPDFGenerationView(PermissionRequiredMixin, RenderPDFView):
     permission_required = "core.test_pdf_rule"
 
 
-@permission_required(
-    "core.mark_notification_as_read_rule", fn=objectgetter_optional(Notification, None, False)
-)
-def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse:
-    """Mark a notification read."""
-    notification = objectgetter_optional(Notification, None, False)(request, id_)
-
-    notification.read = True
-    notification.save()
-
-    # Redirect to dashboard as this is only used from there if JavaScript is unavailable
-    return redirect("index")
+def vue_dummy(request: HttpRequest) -> HttpResponse:
+    # FIXME remove together with URL route and template
+    return render(request, "core/vue_dummy.html", {})
 
 
 @permission_required("core.view_announcements_rule")
diff --git a/aleksis/core/webpack.config.js b/aleksis/core/webpack.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..345972389a066fd8f69ae8b821af3ff6019322f0
--- /dev/null
+++ b/aleksis/core/webpack.config.js
@@ -0,0 +1,90 @@
+const fs = require('fs');
+const path = require('path');
+const webpack = require('webpack');
+const BundleTracker = require('webpack-bundle-tracker');
+const { VueLoaderPlugin } = require('vue-loader');
+
+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(),
+  ],
+  module: {
+    rules: [
+      {
+        test: /\.vue$/,
+        use: {
+          loader: 'vue-loader',
+          options: {
+            transpileOptions: {
+              transforms: {
+                dangerousTaggedTemplateString: true
+              }
+            }
+          }
+        },
+      },
+      {
+        test: /\.(css)$/,
+        use: ['vue-style-loader', 'css-loader'],
+      },
+      {
+        test: /\.s(c|a)ss$/,
+        use: [
+          'vue-style-loader',
+          'css-loader',
+          {
+            loader: 'sass-loader',
+            options: {
+              implementation: require('sass'),
+              sassOptions: {
+                indentedSyntax: true
+              },
+            },
+          },
+        ],
+      },
+      {
+        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 05baa14a8c76f91b3407c47a97c2609c0decad6d..3f350d5f9199ac265b6be906ad284a20705a5bf9 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -16,7 +16,7 @@ AlekSIS will need and use the following paths:
  * `/var/lib/aleksis/media` for file storage (Django media)
  * `/var/backups/aleksis` for backups of database and media files
  * `/usr/local/share/aleksis/static` for static files
- * `/usr/local/share/aleksis/node_modules` for frontend dependencies
+ * `//var/cache/aleksis` for building frontend assets etc.
 
 You can change any of the paths as you like.
 
@@ -76,7 +76,8 @@ Create the directories for storage
 .. code-block:: shell
 
    mkdir -p /etc/aleksis \
-            /usr/share/aleksis/{static,node_modules} \
+            /usr/share/aleksis/static \
+            /var/cache/aleksis \
             /var/lib/aleksis/media \
             /var/backups/aleksis
 
@@ -91,7 +92,7 @@ favourite text editor and adding the following configuration.
 
    static = { root = "/usr/local/share/aleksis/static", url = "/static/" }
    media = { root = "/var/lib/aleksis/media", url = "/media/" }
-   node_modules = { root = "/usr/local/share/aleksis/node_modules" }
+   caching = { dir = "/var/cache/aleksis" }
    secret_key = "SomeRandomValue"
 
    [http]
@@ -142,7 +143,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`:
 
 .. code-block:: shell
    pip3 install aleksis
-   aleksis-admin yarn install
+   aleksis-admin webpack_bundle
    aleksis-admin collectstatic
    aleksis-admin migrate
    aleksis-admin createinitialrevisions
diff --git a/docs/conf.py b/docs/conf.py
index 55cb1422da38894574f40742cfbd068a29e27585..5d7a78f8e5caf4f590838c22d19ae6615be9da12 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -29,9 +29,9 @@ copyright = "2019-2022 The AlekSIS team"
 author = "The AlekSIS Team"
 
 # The short X.Y version
-version = "2.10"
+version = "3.0"
 # The full version, including alpha/beta/rc tags
-release = "2.10.2.dev0"
+release = "3.0.dev0"
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index 25fd2f055a4136e7945f970258ebf282747b9379..1ed39af7a2dc3df00da7ff9fa8f5acdf3a855126 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "AlekSIS-Core"
-version = "2.10.2.dev0"
+version = "3.0.dev0"
 packages = [
     { include = "aleksis" }
 ]
@@ -129,6 +129,8 @@ pycountry = "^22.0.0"
 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/tox.ini b/tox.ini
index ccee8b744148810a678afbc430480fbf19f60051..9b265e53f8a154838b5af4fd2a4d3475e9c69d40 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,7 +10,7 @@ skip_install = true
 envdir = {toxworkdir}/globalenv
 commands_pre =
      poetry install -E ldap
-     poetry run aleksis-admin yarn install
+     poetry run aleksis-admin webpack_bundle
      poetry run aleksis-admin collectstatic --no-input
 commands =
     poetry run pytest --cov=. {posargs} aleksis/