diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index fc9ba63604dabf57e1c4101fc8d7f5700548cc6c..8b0afd86085db5628dcd66b60a2e86133cd59bc2 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -1,6 +1,8 @@
 from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 
+from .util.core_helpers import unread_notifications_badge
+
 MENUS = {
     "NAV_MENU_CORE": [
         {
@@ -15,6 +17,15 @@ MENUS = {
             "icon": "home",
             "validators": ["menu_generator.validators.is_authenticated"],
         },
+        {
+            "name": _("Notifications"),
+            "url": "notifications",
+            "icon": "notifications",
+            "badge": unread_notifications_badge,
+            "validators": [
+                ("aleksis.core.util.predicates.permission_validator", "core.view_notifications",),
+            ],
+        },
         {
             "name": _("Account"),
             "url": "#",
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index ac713bbe54a937d529da6c4e0266209dae593fd5..c118d9790fdeebe7d94906e1d045ea455a1cc8ee 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -16,6 +16,9 @@ rules.add_perm("core", rules.always_allow)
 # View dashboard
 rules.add_perm("core.view_dashboard", has_person)
 
+# View notifications
+rules.add_perm("core.view_notifications", has_person)
+
 # Use search
 search_predicate = has_person & has_global_perm("core.search")
 rules.add_perm("core.search", search_predicate)
diff --git a/aleksis/core/templates/core/notifications.html b/aleksis/core/templates/core/notifications.html
new file mode 100644
index 0000000000000000000000000000000000000000..a49bc9e8e2a8ad87041acbf20e79e0486714cde6
--- /dev/null
+++ b/aleksis/core/templates/core/notifications.html
@@ -0,0 +1,32 @@
+{% extends 'core/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 left">access_time</i> {{ notification.created }}
+          </p>
+          <p>
+            {{ notification.description }}
+          </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 %}
+{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 30c38b11c9c1e884f62f88a0f62b369f55fe2969..9fa05b3e46f1efab08e7b7452a06ce5534dc42eb 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -57,6 +57,7 @@ urlpatterns = [
     path("group/<int:id_>/edit", views.edit_group, name="edit_group_by_id"),
     path("group/<int:id_>/delete", views.delete_group, name="delete_group_by_id"),
     path("", views.index, name="index"),
+    path("notifications/", views.Notifications.as_view(), name="notifications"),
     path("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"),
     path(
         "notifications/mark-read/<int:id_>",
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index 18fcfc241ea76d96312e1339fbde6fd526218307..e1fceb34aae41bcb8a957801c2601e3eead74ee1 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -401,3 +401,8 @@ def queryset_rules_filter(
             wanted_objects.add(item.pk)
 
     return queryset.filter(pk__in=wanted_objects)
+
+
+def unread_notifications_badge(request: HttpRequest) -> int:
+    """Generate badge content with the number of unread notifications."""
+    return request.user.person.notifications.all().filter(read=False).count()
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 41b36acfbcff7573366a2328d2d0f9f2a92fe1df..56eececce3f61d1be4d6f4f195ce7b0186e11fc2 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -109,6 +109,18 @@ def index(request: HttpRequest) -> HttpResponse:
     return render(request, "core/index.html", context)
 
 
+class Notifications(PermissionRequiredMixin, ListView):
+    permission_required = "core.view_notifications"
+    template_name = "core/notifications.html"
+
+    def get_queryset(self) -> QuerySet:
+        return self.request.user.person.notifications.order_by("-created")
+
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        self.get_queryset().filter(read=False).update(read=True)
+        return super().get_context_data(**kwargs)
+
+
 def about(request: HttpRequest) -> HttpResponse:
     """About page listing all apps."""
     context = {}