diff --git a/aleksis/core/frontend/components/app/AccountMenu.vue b/aleksis/core/frontend/components/app/AccountMenu.vue
index 70e8c52fa770b743c138c6212f296b223be83340..ed5ee1ba5873756b37be18e21cea7c0603f1b8e2 100644
--- a/aleksis/core/frontend/components/app/AccountMenu.vue
+++ b/aleksis/core/frontend/components/app/AccountMenu.vue
@@ -52,7 +52,11 @@
           <v-list-item-icon>
             <v-icon v-if="menuItem.icon">{{ menuItem.icon }}</v-icon>
           </v-list-item-icon>
-          <v-list-item-title>{{ $t(menuItem.titleKey) }}</v-list-item-title>
+          <v-list-item-title>{{
+            !menuItem.rawTitleString
+              ? $t(menuItem.titleKey)
+              : menuItemm.rawTitleString
+          }}</v-list-item-title>
         </v-list-item>
       </div>
     </v-list>
diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue
index 8896f20785f8992c81e5cecf4488536b9d29caf8..e11725a36e9dc33310750397a68305b3af05c566 100644
--- a/aleksis/core/frontend/components/app/App.vue
+++ b/aleksis/core/frontend/components/app/App.vue
@@ -82,8 +82,17 @@
             </message-box>
           </div>
 
+          <error-page
+            v-if="error404"
+            short-error-message-key="network_errors.error_404"
+            long-error-message-key="network_errors.page_not_found"
+            redirect-button-text-key="network_errors.back_to_start"
+            redirect-route-name="dashboard"
+            redirect-button-icon="mdi-home-outline"
+          >
+          </error-page>
           <router-view
-            v-if="
+            v-else-if="
               !$route.meta.permission ||
               checkPermission($route.meta.permission) ||
               $route.name === 'dashboard'
@@ -216,6 +225,7 @@ import gqlSystemProperties from "./systemProperties.graphql";
 import useRegisterSWMixin from "../../mixins/useRegisterSW";
 import offlineMixin from "../../mixins/offline";
 import menusMixin from "../../mixins/menus";
+import routesMixin from "../../mixins/routes";
 
 export default {
   data() {
@@ -224,6 +234,7 @@ export default {
       whoAmI: null,
       systemProperties: null,
       messages: null,
+      error404: false,
     };
   },
   apollo: {
@@ -259,6 +270,16 @@ export default {
       },
       deep: true,
     },
+    $route: {
+      handler(newRoute) {
+        if (newRoute.matched.length == 0) {
+          this.error404 = true;
+        } else {
+          this.error404 = false;
+        }
+      },
+      immediate: true,
+    },
   },
   name: "App",
   components: {
@@ -270,7 +291,7 @@ export default {
     SideNav,
     SnackbarItem,
   },
-  mixins: [useRegisterSWMixin, offlineMixin, menusMixin],
+  mixins: [useRegisterSWMixin, offlineMixin, menusMixin, routesMixin],
 };
 </script>
 
diff --git a/aleksis/core/frontend/components/app/SideNav.vue b/aleksis/core/frontend/components/app/SideNav.vue
index 972b24eb79e9200cc7d5b16638ee37a640a8b458..0975c580cda88d03fd8a5d34b2fb16425ece69ee 100644
--- a/aleksis/core/frontend/components/app/SideNav.vue
+++ b/aleksis/core/frontend/components/app/SideNav.vue
@@ -19,11 +19,19 @@
             v-if="menuItem.subMenu.length > 0"
             href="#!"
             :prepend-icon="menuItem.icon"
-            :value="$route.matched.slice(-2).shift().name === menuItem.name"
+            :value="
+              $route.matched.slice(-2).shift()
+                ? $route.matched.slice(-2).shift().name === menuItem.name
+                : false
+            "
           >
             <template #activator>
               <v-list-item-title
-                >{{ $t(menuItem.titleKey) }}
+                >{{
+                  !menuItem.rawTitleString
+                    ? $t(menuItem.titleKey)
+                    : menuItem.rawTitleString
+                }}
               </v-list-item-title>
             </template>
             <v-list-item
@@ -38,7 +46,11 @@
                 <v-icon v-if="subMenuItem.icon">{{ subMenuItem.icon }} </v-icon>
               </v-list-item-icon>
               <v-list-item-title
-                >{{ $t(subMenuItem.titleKey) }}
+                >{{
+                  !subMenuItem.rawTitleString
+                    ? $t(subMenuItem.titleKey)
+                    : subMenuItem.rawTitleString
+                }}
               </v-list-item-title>
             </v-list-item>
           </v-list-group>
@@ -52,7 +64,11 @@
             <v-list-item-icon>
               <v-icon v-if="menuItem.icon">{{ menuItem.icon }}</v-icon>
             </v-list-item-icon>
-            <v-list-item-title>{{ $t(menuItem.titleKey) }}</v-list-item-title>
+            <v-list-item-title>{{
+              !menuItem.rawTitleString
+                ? $t(menuItem.titleKey)
+                : menuItem.rawTitleString
+            }}</v-list-item-title>
           </v-list-item>
         </div>
       </v-list-item-group>
diff --git a/aleksis/core/frontend/components/app/dynamicRoutes.graphql b/aleksis/core/frontend/components/app/dynamicRoutes.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..49c208b729f4185b0053e8bfbff13fd3ca8c78f5
--- /dev/null
+++ b/aleksis/core/frontend/components/app/dynamicRoutes.graphql
@@ -0,0 +1,18 @@
+{
+  dynamicRoutes {
+    parentRouteName
+
+    routePath
+    routeName
+
+    displayAccountMenu
+    displaySidenavMenu
+    menuNewTab
+
+    menuTitle
+    menuIcon
+
+    menuPermission
+    routePermission
+  }
+}
diff --git a/aleksis/core/frontend/mixins/routes.js b/aleksis/core/frontend/mixins/routes.js
new file mode 100644
index 0000000000000000000000000000000000000000..813edd5777e9868680d5a5d9e8e495d70ef35790
--- /dev/null
+++ b/aleksis/core/frontend/mixins/routes.js
@@ -0,0 +1,60 @@
+import gqlDynamicRoutes from "../components/app/dynamicRoutes.graphql";
+
+/**
+ * Vue mixin containing code getting dynamically added routes from other apps.
+ *
+ * Only used by main App component, but factored out for readability.
+ */
+const routesMixin = {
+  data() {
+    return {
+      dynamicRoutes: null,
+    };
+  },
+  apollo: {
+    dynamicRoutes: {
+      query: gqlDynamicRoutes,
+      pollInterval: 60000,
+    },
+  },
+  watch: {
+    dynamicRoutes: {
+      handler(newDynamicRoutes) {
+        for (const route of newDynamicRoutes) {
+          if (route) {
+            console.debug("Adding new dynamic route:", route.routeName);
+            let routeEntry = {
+              path: route.routePath,
+              name: route.routeName,
+              component: () => import("../components/LegacyBaseTemplate.vue"),
+              props: {
+                byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+              },
+              meta: {
+                inMenu: route.displaySidenavMenu,
+                inAccountMenu: route.displayAccountMenu,
+                icon: route.menuIcon,
+                rawTitleString: route.menuTitle,
+                menuPermission: route.menuPermission,
+                permission: route.routePermission,
+                newTab: route.menuNewTab,
+              },
+            };
+
+            if (route.parentRouteName) {
+              this.$router.addRoute(route.parentRouteName, routeEntry);
+            } else {
+              this.$router.addRoute(routeEntry);
+            }
+          }
+        }
+
+        this.getPermissionNames();
+        this.buildMenus();
+      },
+      deep: true,
+    },
+  },
+};
+
+export default routesMixin;
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index 225bbef90d58f5428e419c84433c6956d39057c9..9d4a03d40d224ab5de3e6d1f4d4ca27e3661b114 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -1064,18 +1064,4 @@ for (const [appName, appRoutes] of Object.entries(appObjects)) {
   });
 }
 
-// Fallback route defined last to ensure 404 view works
-routes.push({
-  path: "/*",
-  component: () => import("./components/app/ErrorPage.vue"),
-  name: "core.error404",
-  props: {
-    shortErrorMessageKey: "network_errors.error_404",
-    longErrorMessageKey: "network_errors.page_not_found",
-    redirectButtonTextKey: "network_errors.back_to_start",
-    redirectRouteName: "dashboard",
-    redirectButtonIcon: "mdi-home-outline",
-  },
-});
-
 export default routes;
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 04d7703ca42a6f87ea2fcd41487d9e173022d2cb..1a370c2c68af1569f92a9cc09ada7f8b5f671649 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -1093,6 +1093,14 @@ class CustomMenuItem(ExtensibleModel):
         return reverse("admin:core_custommenuitem_change", args=[self.id])
 
 
+class DynamicRoute(RegistryObject):
+    """Define a dynamic route.
+
+    Dynamic routes should be used to register Vue routes dynamically, e. g.
+    when an app is supposed to show menu items for dynamically creatable objects.
+    """
+
+
 class GroupType(ExtensibleModel):
     """Group type model.
 
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index 70a2b23910a06039aea1f88d753cd205cbe75f6d..6b442beab63e67ee8f09006f6b325a0917d3cdab 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -9,11 +9,12 @@ from haystack.inputs import AutoQuery
 from haystack.query import SearchQuerySet
 from haystack.utils.loading import UnifiedIndex
 
-from ..models import CustomMenu, Notification, PDFFile, Person, TaskUserAssignment
+from ..models import CustomMenu, DynamicRoute, Notification, PDFFile, Person, TaskUserAssignment
 from ..util.apps import AppConfig
 from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person
 from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
 from .custom_menu import CustomMenuType
+from .dynamic_routes import DynamicRouteType
 from .group import GroupType  # noqa
 from .installed_apps import AppType
 from .message import MessageType
@@ -53,6 +54,8 @@ class Query(graphene.ObjectType):
 
     custom_menu_by_name = graphene.Field(CustomMenuType, name=graphene.String())
 
+    dynamic_routes = graphene.List(DynamicRouteType)
+
     def resolve_ping(root, info, payload) -> str:
         return payload
 
@@ -136,6 +139,14 @@ class Query(graphene.ObjectType):
     def resolve_custom_menu_by_name(root, info, name, **kwargs):
         return CustomMenu.get_default(name)
 
+    def resolve_dynamic_routes(root, info, **kwargs):
+        dynamic_routes = []
+
+        for dynamic_route_object in DynamicRoute.registered_objects_dict.values():
+            dynamic_routes += dynamic_route_object.get_dynamic_routes()
+
+        return dynamic_routes
+
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
diff --git a/aleksis/core/schema/dynamic_routes.py b/aleksis/core/schema/dynamic_routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c2fd6336ddb05220447e5ac7df58bbbf98ef74f
--- /dev/null
+++ b/aleksis/core/schema/dynamic_routes.py
@@ -0,0 +1,18 @@
+import graphene
+
+
+class DynamicRouteType(graphene.ObjectType):
+    parent_route_name = graphene.String()
+
+    route_path = graphene.String()
+    route_name = graphene.String()
+
+    display_account_menu = graphene.Boolean()
+    display_sidenav_menu = graphene.Boolean()
+    menu_new_tab = graphene.Boolean()
+
+    menu_title = graphene.String()
+    menu_icon = graphene.String()
+
+    menu_permission = graphene.String()
+    route_permission = graphene.String()