diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000000000000000000000000000000000000..996cd5aad4d7331e9c74dcaa7eb6483a5116eed9
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,216 @@
+module.exports = {
+  extends: [
+    "eslint:recommended",
+    "plugin:vue/strongly-recommended",
+    //    "plugin:prettier/recommended",
+    "plugin:@intlify/vue-i18n/recommended",
+  ],
+  rules: {
+    "no-unused-vars": "warn",
+    "vue/no-unused-vars": "off",
+    "vue/multi-word-component-names": "off",
+    "@intlify/vue-i18n/key-format-style": [
+      "error",
+      "snake_case",
+      {
+        splitByDots: false,
+      },
+    ],
+    // "@intlify/vue-i18n/no-unused-keys": ["warn", {}],
+    "@intlify/vue-i18n/no-raw-text": [
+      "error",
+      {
+        ignoreNodes: ["v-icon"],
+        ignorePattern: "^[-–—·#:()\\[\\]&\\.\\s]+$",
+      },
+    ],
+    // Fixes for prettier (avoid eslint-config-prettier)
+    // The following rules can be used in some cases. See the README for more
+    // information. (These are marked with `0` instead of `"off"` so that a
+    // script can distinguish them.)
+    curly: 0,
+    "lines-around-comment": 0,
+    "max-len": 0,
+    "no-confusing-arrow": 0,
+    "no-mixed-operators": 0,
+    "no-tabs": 0,
+    "no-unexpected-multiline": 0,
+    quotes: 0,
+    "@typescript-eslint/quotes": 0,
+    "babel/quotes": 0,
+    "vue/html-self-closing": 0,
+    "vue/max-len": 0,
+
+    // The rest are rules that you never need to enable when using Prettier.
+    "array-bracket-newline": "off",
+    "array-bracket-spacing": "off",
+    "array-element-newline": "off",
+    "arrow-parens": "off",
+    "arrow-spacing": "off",
+    "block-spacing": "off",
+    "brace-style": "off",
+    "comma-dangle": "off",
+    "comma-spacing": "off",
+    "comma-style": "off",
+    "computed-property-spacing": "off",
+    "dot-location": "off",
+    "eol-last": "off",
+    "func-call-spacing": "off",
+    "function-call-argument-newline": "off",
+    "function-paren-newline": "off",
+    "generator-star": "off",
+    "generator-star-spacing": "off",
+    "implicit-arrow-linebreak": "off",
+    indent: "off",
+    "jsx-quotes": "off",
+    "key-spacing": "off",
+    "keyword-spacing": "off",
+    "linebreak-style": "off",
+    "multiline-ternary": "off",
+    "newline-per-chained-call": "off",
+    "new-parens": "off",
+    "no-arrow-condition": "off",
+    "no-comma-dangle": "off",
+    "no-extra-parens": "off",
+    "no-extra-semi": "off",
+    "no-floating-decimal": "off",
+    "no-mixed-spaces-and-tabs": "off",
+    "no-multi-spaces": "off",
+    "no-multiple-empty-lines": "off",
+    "no-reserved-keys": "off",
+    "no-space-before-semi": "off",
+    "no-trailing-spaces": "off",
+    "no-whitespace-before-property": "off",
+    "no-wrap-func": "off",
+    "nonblock-statement-body-position": "off",
+    "object-curly-newline": "off",
+    "object-curly-spacing": "off",
+    "object-property-newline": "off",
+    "one-var-declaration-per-line": "off",
+    "operator-linebreak": "off",
+    "padded-blocks": "off",
+    "quote-props": "off",
+    "rest-spread-spacing": "off",
+    semi: "off",
+    "semi-spacing": "off",
+    "semi-style": "off",
+    "space-after-function-name": "off",
+    "space-after-keywords": "off",
+    "space-before-blocks": "off",
+    "space-before-function-paren": "off",
+    "space-before-function-parentheses": "off",
+    "space-before-keywords": "off",
+    "space-in-brackets": "off",
+    "space-in-parens": "off",
+    "space-infix-ops": "off",
+    "space-return-throw-case": "off",
+    "space-unary-ops": "off",
+    "space-unary-word-ops": "off",
+    "switch-colon-spacing": "off",
+    "template-curly-spacing": "off",
+    "template-tag-spacing": "off",
+    "unicode-bom": "off",
+    "wrap-iife": "off",
+    "wrap-regex": "off",
+    "yield-star-spacing": "off",
+    "@babel/object-curly-spacing": "off",
+    "@babel/semi": "off",
+    "@typescript-eslint/brace-style": "off",
+    "@typescript-eslint/comma-dangle": "off",
+    "@typescript-eslint/comma-spacing": "off",
+    "@typescript-eslint/func-call-spacing": "off",
+    "@typescript-eslint/indent": "off",
+    "@typescript-eslint/keyword-spacing": "off",
+    "@typescript-eslint/member-delimiter-style": "off",
+    "@typescript-eslint/no-extra-parens": "off",
+    "@typescript-eslint/no-extra-semi": "off",
+    "@typescript-eslint/object-curly-spacing": "off",
+    "@typescript-eslint/semi": "off",
+    "@typescript-eslint/space-before-blocks": "off",
+    "@typescript-eslint/space-before-function-paren": "off",
+    "@typescript-eslint/space-infix-ops": "off",
+    "@typescript-eslint/type-annotation-spacing": "off",
+    "babel/object-curly-spacing": "off",
+    "babel/semi": "off",
+    "flowtype/boolean-style": "off",
+    "flowtype/delimiter-dangle": "off",
+    "flowtype/generic-spacing": "off",
+    "flowtype/object-type-curly-spacing": "off",
+    "flowtype/object-type-delimiter": "off",
+    "flowtype/quotes": "off",
+    "flowtype/semi": "off",
+    "flowtype/space-after-type-colon": "off",
+    "flowtype/space-before-generic-bracket": "off",
+    "flowtype/space-before-type-colon": "off",
+    "flowtype/union-intersection-spacing": "off",
+    "react/jsx-child-element-spacing": "off",
+    "react/jsx-closing-bracket-location": "off",
+    "react/jsx-closing-tag-location": "off",
+    "react/jsx-curly-newline": "off",
+    "react/jsx-curly-spacing": "off",
+    "react/jsx-equals-spacing": "off",
+    "react/jsx-first-prop-new-line": "off",
+    "react/jsx-indent": "off",
+    "react/jsx-indent-props": "off",
+    "react/jsx-max-props-per-line": "off",
+    "react/jsx-newline": "off",
+    "react/jsx-one-expression-per-line": "off",
+    "react/jsx-props-no-multi-spaces": "off",
+    "react/jsx-tag-spacing": "off",
+    "react/jsx-wrap-multilines": "off",
+    "standard/array-bracket-even-spacing": "off",
+    "standard/computed-property-even-spacing": "off",
+    "standard/object-curly-even-spacing": "off",
+    "unicorn/empty-brace-spaces": "off",
+    "unicorn/no-nested-ternary": "off",
+    "unicorn/number-literal-case": "off",
+    "vue/array-bracket-newline": "off",
+    "vue/array-bracket-spacing": "off",
+    "vue/arrow-spacing": "off",
+    "vue/block-spacing": "off",
+    "vue/block-tag-newline": "off",
+    "vue/brace-style": "off",
+    "vue/comma-dangle": "off",
+    "vue/comma-spacing": "off",
+    "vue/comma-style": "off",
+    "vue/dot-location": "off",
+    "vue/func-call-spacing": "off",
+    "vue/html-closing-bracket-newline": "off",
+    "vue/html-closing-bracket-spacing": "off",
+    "vue/html-end-tags": "off",
+    "vue/html-indent": "off",
+    "vue/html-quotes": "off",
+    "vue/key-spacing": "off",
+    "vue/keyword-spacing": "off",
+    "vue/max-attributes-per-line": "off",
+    "vue/multiline-html-element-content-newline": "off",
+    "vue/multiline-ternary": "off",
+    "vue/mustache-interpolation-spacing": "off",
+    "vue/no-extra-parens": "off",
+    "vue/no-multi-spaces": "off",
+    "vue/no-spaces-around-equal-signs-in-attribute": "off",
+    "vue/object-curly-newline": "off",
+    "vue/object-curly-spacing": "off",
+    "vue/object-property-newline": "off",
+    "vue/operator-linebreak": "off",
+    "vue/quote-props": "off",
+    "vue/script-indent": "off",
+    "vue/singleline-html-element-content-newline": "off",
+    "vue/space-in-parens": "off",
+    "vue/space-infix-ops": "off",
+    "vue/space-unary-ops": "off",
+    "vue/template-curly-spacing": "off",
+  },
+  settings: {
+    "vue-i18n": {
+      localeDir: "./aleksis/core/frontend/messages/*.{json}",
+      messageSyntaxVersion: "^8.0.0",
+    },
+  },
+  env: {
+    es2021: true,
+  },
+  parserOptions: {
+    ecmaVersion: "latest",
+  },
+};
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 22daa3005c2ff44f91964a1ecb4627db0a0afe2c..87137173c9f87a1ff436deda92cd180b274d29da 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,17 +1,17 @@
 include:
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/general.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/prepare/lock.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/lint.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/security.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/build/dist.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/build/docs.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/publish/pypi.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/deploy/pages.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/general.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/prepare/lock.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/lint.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/security.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/build/dist.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/build/docs.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/publish/pypi.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/deploy/pages.yml
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..38d141b743fd55678f50077c0617924475817095
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,91 @@
+# Byte-compiled / optimized / DLL files
+*$py.class
+*.py[cod]
+__pycache__/
+
+# Distribution / packaging
+*.egg
+*.egg-info/
+.Python
+.eggs/
+.installed.cfg
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+
+# Installer logs
+pip-delete-this-directory.txt
+pip-log.txt
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# pyenv
+.python-version
+
+# Environments
+.env
+.venv
+ENV/
+env/
+venv/
+
+# Editors
+*~
+DEADJOE
+\#*#
+
+# IntelliJ
+.idea
+.idea/
+
+# Database
+db.sqlite3
+
+# Sphinx
+docs/_build/
+
+# TeX
+*.aux
+
+# Generated files
+/node_modules/
+/static/
+/whoosh_index/
+poetry.lock
+
+.coverage
+.mypy_cache/
+.tox/
+htmlcov/
+maintenance_mode_state.txt
+media/
+package-lock.json
+yarn.lock
+
+# VSCode
+.vscode/
+.history/
+*.code-workspace
+
+/cache
+
+# Add HTML files to avoid problems with unsupported Django templates
+*.html
+
+# Do not check/reformat generated files
+aleksis/core/util/licenses.json
+.vite/
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0ed7fe7ce5203909ec91f09c227deb0bb94390c7..a261e9075dd14243d8069c4a38838a543439f848 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,8 +6,21 @@ All notable changes to this project will be documented in this file.
 The format is based on `Keep a Changelog`_,
 and this project adheres to `Semantic Versioning`_.
 
-Unreleased
-----------
+`3.0b0` - 2023-02-16
+--------------------
+
+This version requires AlekSIS-Core 3.0. It is incompatible with any previous
+version.
+
+Removed
+~~~~~~~
+
+* Legacy menu integration for AlekSIS-Core pre-3.0
+
+Added
+~~~~~
+
+* Add SPA support for AlekSIS-Core 3.0
 
 `2.2`_ - 2022-06-23
 -------------------
@@ -68,8 +81,9 @@ Added
 .. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/
 .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html
 
-.. _2.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b0
-.. _2.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b1
-.. _2.0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0
-.. _2.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.1
-.. _2.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.2
+.. _2.0b0: https://edugit.org/AlekSIS/official/AlekSIS-App-Resint/-/tags/2.0b0
+.. _2.0b1: https://edugit.org/AlekSIS/official/AlekSIS-App-Resint/-/tags/2.0b1
+.. _2.0: https://edugit.org/AlekSIS/official/AlekSIS-App-Resint/-/tags/2.0
+.. _2.1: https://edugit.org/AlekSIS/official/AlekSIS-App-Resint/-/tags/2.1
+.. _2.2: https://edugit.org/AlekSIS/official/AlekSIS-App-Resint/-/tags/2.2
+.. _3.0b0: https://edugit.org/AlekSIS/official/AlekSIS-App-Resint/-/tags/3.0b0
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..2b6e55b7222a392980b7f97473b37f75604a6ad5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,2 @@
+ARG APPS="AlekSIS-App-Resint"
+FROM registry.edugit.org/aleksis/official/aleksis-core:master
diff --git a/aleksis/apps/resint/frontend/index.js b/aleksis/apps/resint/frontend/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..b8b497d77a5bc75236845020ac20d0f2aea50e11
--- /dev/null
+++ b/aleksis/apps/resint/frontend/index.js
@@ -0,0 +1,150 @@
+import { hasPersonValidator } from "aleksis.core/routeValidators";
+
+export default {
+  meta: {
+    inMenu: true,
+    titleKey: "resint.menu_title",
+    icon: "mdi-open-in-app",
+    validators: [hasPersonValidator],
+  },
+  children: [
+    {
+      path: "",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.posterIndex",
+      meta: {
+        inMenu: true,
+        titleKey: "resint.manage_posters.menu_title",
+        icon: "mdi-file-upload-outline",
+        permission: "resint.view_posters_rule",
+      },
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "upload/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.posterUpload",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: ":pk/edit/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.posterEdit",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: ":pk/delete/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.posterDelete",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: ":slug.pdf",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.posterShowCurrent",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "groups/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.posterGroupList",
+      meta: {
+        inMenu: true,
+        titleKey: "resint.poster_groups.menu_title",
+        icon: "mdi-folder-multiple-outline",
+        permission: "resint.view_postergroups_rule",
+      },
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "groups/create/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.createPosterGroup",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "groups/:pk/edit/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.editPosterGroup",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "groups/:pk/delete/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.deletePosterGroup",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "live/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.liveDocuments",
+      meta: {
+        inMenu: true,
+        titleKey: "resint.live_documents.menu_title",
+        icon: "mdi-update",
+        permission: "resint.view_livedocuments_rule",
+      },
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "live/:app/:model/create/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.createLiveDocument",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "live/:pk/edit/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.editLiveDocument",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "live_documents/:pk/delete/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.deleteLiveDocument",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "live_documents/:slug.pdf",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.showLiveDocument",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "api/live_documents/:slug.pdf",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "resint.apiShowLiveDocument",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+  ],
+};
diff --git a/aleksis/apps/resint/frontend/messages/de.json b/aleksis/apps/resint/frontend/messages/de.json
new file mode 100644
index 0000000000000000000000000000000000000000..bb7e90c25aab9103508a9d2d5f8504151ae8b1e6
--- /dev/null
+++ b/aleksis/apps/resint/frontend/messages/de.json
@@ -0,0 +1,14 @@
+{
+  "resint": {
+    "menu_title": "Dokumente",
+    "manage_posters": {
+      "menu_title": "Dokumente verwalten"
+    },
+    "poster_groups": {
+      "menu_title": "Dokumentengruppen"
+    },
+    "live_documents": {
+      "menu_title": "Live-Dokumente"
+    }
+  }
+}
diff --git a/aleksis/apps/resint/frontend/messages/en.json b/aleksis/apps/resint/frontend/messages/en.json
new file mode 100644
index 0000000000000000000000000000000000000000..376a6a7ea193f002f41a927b604563e5cbdba081
--- /dev/null
+++ b/aleksis/apps/resint/frontend/messages/en.json
@@ -0,0 +1,14 @@
+{
+  "resint": {
+    "menu_title": "Documents",
+    "manage_posters": {
+      "menu_title": "Manage posters"
+    },
+    "poster_groups": {
+      "menu_title": "Poster groups"
+    },
+    "live_documents": {
+      "menu_title": "Live documents"
+    }
+  }
+}
diff --git a/aleksis/apps/resint/menus.py b/aleksis/apps/resint/menus.py
deleted file mode 100644
index 2967079ee5c00b2955b5ce5c489fe38d7b06f5a0..0000000000000000000000000000000000000000
--- a/aleksis/apps/resint/menus.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from typing import Any, Dict, List
-
-from django.apps import apps
-from django.urls import reverse
-from django.utils.translation import ugettext_lazy as _
-
-
-def _get_menu_entries() -> List[Dict[str, Any]]:
-    """Build menu entries for all poster groups.
-
-    This will include only poster groups where ``show_in_menu`` is enabled.
-    """
-    PosterGroup = apps.get_model("resint", "PosterGroup")
-    return [
-        {
-            "name": group.name,
-            "url": reverse("poster_show_current", args=[group.slug]),
-            "icon": "picture_as_pdf",
-            "validators": [
-                (
-                    "aleksis.apps.resint.rules.permission_validator",
-                    "resint.view_poster_pdf_menu",
-                    group,
-                ),
-            ],
-            "new_tab": True,
-        }
-        for group in PosterGroup.objects.all()
-    ]
-
-
-class MENUS:
-    def get(menu_name, default=None):
-        menus = {
-            "NAV_MENU_CORE": [
-                {
-                    "name": _("Documents"),
-                    "url": "#",
-                    "icon": "open_in_browser",
-                    "root": True,
-                    "validators": [
-                        (
-                            "aleksis.core.util.predicates.permission_validator",
-                            "resint.view_poster_menu",
-                        ),
-                    ],
-                    "submenu": [
-                        {
-                            "name": _("Manage posters"),
-                            "url": "poster_index",
-                            "icon": "file_upload",
-                            "validators": [
-                                (
-                                    "aleksis.core.util.predicates.permission_validator",
-                                    "resint.view_posters_rule",
-                                ),
-                            ],
-                        },
-                        {
-                            "name": _("Poster groups"),
-                            "url": "poster_group_list",
-                            "icon": "topic",
-                            "validators": [
-                                (
-                                    "aleksis.core.util.predicates.permission_validator",
-                                    "resint.view_postergroups_rule",
-                                ),
-                            ],
-                        },
-                        {
-                            "name": _("Live documents"),
-                            "url": "live_documents",
-                            "icon": "update",
-                            "validators": [
-                                (
-                                    "aleksis.core.util.predicates.permission_validator",
-                                    "resint.view_livedocuments_rule",
-                                ),
-                            ],
-                        },
-                    ],
-                }
-            ]
-            + _get_menu_entries(),
-        }
-
-        return menus.get(menu_name, default)
diff --git a/aleksis/apps/resint/models.py b/aleksis/apps/resint/models.py
index eab9cc406f0dbb80a7b8accf9e7dbd595bc3eef7..aae47aa1693869acd96a5e25b6cd7d7e29a86f98 100644
--- a/aleksis/apps/resint/models.py
+++ b/aleksis/apps/resint/models.py
@@ -4,6 +4,7 @@ from typing import Any, Optional
 from django.core.files import File
 from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator
 from django.db import models
+from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
@@ -15,9 +16,43 @@ from celery.states import SUCCESS
 from reversion.models import Revision, Version
 
 from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel
+from aleksis.core.models import DynamicRoute
 from aleksis.core.util.pdf import generate_pdf_from_template
 
 
+class PosterGroupDynamicRoute(DynamicRoute):
+    @classmethod
+    def get_dynamic_routes(cls):
+        poster_groups = PosterGroup.objects.all()
+
+        dynamic_routes = []
+
+        for poster_group in poster_groups:
+            dynamic_routes.append(cls.get_route_data(poster_group))
+
+        return dynamic_routes
+
+    @classmethod
+    def get_route_data(cls, instance):
+        dynamic_route = {}
+
+        dynamic_route["parent_route_name"] = ""
+
+        dynamic_route["route_path"] = reverse("poster_show_current", args=[instance.slug])
+        dynamic_route["route_name"] = f"resint.posterGroup.{instance.slug}"
+
+        dynamic_route["display_account_menu"] = False
+        dynamic_route["display_sidenav_menu"] = instance.show_in_menu
+        dynamic_route["menu_new_tab"] = True
+
+        dynamic_route["menu_title"] = instance.name
+        dynamic_route["menu_icon"] = "mdi-file-pdf-box"
+
+        dynamic_route["route_permission"] = "" if instance.public else "resint.view_poster_pdf_menu"
+
+        return dynamic_route
+
+
 class PosterGroup(ExtensibleModel):
     """Group for time-based documents, called posters."""
 
diff --git a/aleksis/apps/resint/rules.py b/aleksis/apps/resint/rules.py
index f1d9f72db0f2e38dfc1b9f20f4c74876a2e10432..a30a6811217ffa0317732bfebd2a93862f44ed14 100644
--- a/aleksis/apps/resint/rules.py
+++ b/aleksis/apps/resint/rules.py
@@ -102,8 +102,10 @@ view_poster_pdf_predicate = is_public_poster_group | (
 )
 add_perm("resint.view_poster_pdf", view_poster_pdf_predicate)
 
-# View menu entry for single posters
-view_poster_pdf_menu_predicate = show_poster_group_in_menu & view_poster_pdf_predicate
+# View poster PDF file in menu
+view_poster_pdf_menu_predicate = has_person & (
+    has_global_perm("resint.view_postergroup") | has_global_perm("resint.view_poster")
+)
 add_perm("resint.view_poster_pdf_menu", view_poster_pdf_menu_predicate)
 
 # Show the poster manage menu
diff --git a/docs/conf.py b/docs/conf.py
index e09e69554ffa82cf8a1b5edf939ba08914e6baeb..5ab41510c32eec41c4627780b6233e92bc1c1269 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -29,9 +29,9 @@ copyright = "2018-2022 The AlekSIS team"
 author = "The AlekSIS Team"
 
 # The short X.Y version
-version = "2.2"
+version = "3.0"
 # The full version, including alpha/beta/rc tags
-release = "2.2.1.dev0"
+release = "3.0b0"
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index 5a7e4ae64e67a3ba4cc98a32fa2944b3fef7ee1e..fd411643ec458c931758ade53ab5999d0b5b4072 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "AlekSIS-App-Resint"
-version = "2.2.1.dev0"
+version = "3.0b0"
 packages = [
     { include = "aleksis" }
 ]
@@ -41,7 +41,7 @@ secondary = true
 
 [tool.poetry.dependencies]
 python = "^3.9"
-AlekSIS-Core = "^2.1"
+AlekSIS-Core = "^3.0b0"
 
 [tool.poetry.dev-dependencies]
 aleksis-builddeps = "*"
diff --git a/tox.ini b/tox.ini
index 749e0606f4f02fcbd1649627219b15850cbc0a90..6e4b77ab1ded935257117696975c2150772cd85c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,16 +1,15 @@
 [tox]
 skipsdist = True
 skip_missing_interpreters = true
-envlist = py37,py38,py39
+envlist = py39,py310,py311
 
 [testenv]
 whitelist_externals = poetry
-		      sudo
 skip_install = true
 envdir = {toxworkdir}/globalenv
 commands_pre =
      poetry install
-     poetry run aleksis-admin yarn install
+     poetry run aleksis-admin vite build
      poetry run aleksis-admin collectstatic --no-input
 commands =
     poetry run pytest --cov=. {posargs} aleksis/
@@ -27,6 +26,8 @@ commands =
     poetry run black --check --diff aleksis/
     poetry run isort -c --diff --stdout aleksis/
     poetry run flake8 {posargs} aleksis/
+    poetry run sh -c "aleksis-admin yarn run prettier --check --ignore-path={toxinidir}/.prettierignore {toxinidir}"
+    poetry run sh -c "aleksis-admin yarn run eslint {toxinidir}/aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=."
 
 [testenv:security]
 commands =
@@ -46,6 +47,7 @@ commands = poetry run make -C docs/ html {posargs}
 commands =
     poetry run isort aleksis/
     poetry run black aleksis/
+    poetry run sh -c "aleksis-admin yarn run prettier --write --ignore-path={toxinidir}/.prettierignore {toxinidir}"
 
 [testenv:makemessages]
 commands =