diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index 2e71c357384c1bc7e4ffa4ff1485f4edc4ffc7de..9b1c42e931235333fb8d4a9dca55cd7f977b47de 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -93,6 +93,17 @@ MENUS = {
                         ),
                     ],
                 },
+                {
+                    "name": _("Dashboard widgets"),
+                    "url": "dashboard_widgets",
+                    "icon": "dashboard",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "core.view_dashboardwidget",
+                        ),
+                    ],
+                },
                 {
                     "name": _("Data management"),
                     "url": "data_management",
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index 4b0cf23d6119a05075dc36c32aaf366d0588e80f..2e36bc08bf3a307c0b8a116a5731f0f4793f2475 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -278,3 +278,16 @@ view_group_stats_predicate = has_person & (
     has_global_perm("core.view_group_stats") | has_object_perm("core.view_group_stats")
 )
 rules.add_perm("core.view_group_stats", view_group_stats_predicate)
+
+
+view_dashboard_widget_predicate = has_person & has_global_perm("core.view_dashboardwidget")
+rules.add_perm("core.view_dashboardwidget", view_dashboard_widget_predicate)
+
+create_dashboard_widget_predicate = has_person & has_global_perm("core.add_dashboardwidget")
+rules.add_perm("core.create_dashboardwidget", create_dashboard_widget_predicate)
+
+edit_dashboard_widget_predicate = has_person & has_global_perm("core.change_dashboardwidget")
+rules.add_perm("core.edit_dashboardwidget", edit_dashboard_widget_predicate)
+
+delete_dashboard_widget_predicate = has_person & has_global_perm("core.delete_dashboardwidget")
+rules.add_perm("core.delete_dashboardwidget", delete_dashboard_widget_predicate)
diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py
index f562b61a151487c5047ef2861255a00ef7a5f785..54d3f3e5c75e4c5ac0dca4b8ccfe868f70b7efb2 100644
--- a/aleksis/core/tables.py
+++ b/aleksis/core/tables.py
@@ -70,3 +70,24 @@ class GroupTypesTable(tables.Table):
     delete = tables.LinkColumn(
         "delete_group_type_by_id", args=[A("id")], verbose_name=_("Delete"), text=_("Delete")
     )
+
+
+class DashboardWidgetTable(tables.Table):
+    """Table to list dashboard widgets."""
+
+    class Meta:
+        attrs = {"class": "responsive-table highlight"}
+
+    widget_name = tables.Column(accessor="pk")
+    title = tables.LinkColumn("edit_dashboard_widget", args=[A("id")])
+    active = tables.BooleanColumn(yesno="check,cancel", attrs={"span": {"class": "material-icons"}})
+    delete = tables.LinkColumn(
+        "delete_dashboard_widget",
+        args=[A("id")],
+        text=_("Delete"),
+        attrs={"a": {"class": "btn-flat waves-effect waves-red red-text"}},
+        verbose_name=_("Actions"),
+    )
+
+    def render_widget_name(self, value, record):
+        return record._meta.verbose_name
diff --git a/aleksis/core/templates/core/dashboard_widget/create.html b/aleksis/core/templates/core/dashboard_widget/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..cfaa296eefc7afb0abce56ce1802eeba2ef70a76
--- /dev/null
+++ b/aleksis/core/templates/core/dashboard_widget/create.html
@@ -0,0 +1,23 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load material_form i18n data_helpers %}
+
+{% block browser_title %}
+  {% verbose_name_object model as widget_title %}
+  {% blocktrans with widget=widget_title %}Create {{ widget }}{% endblocktrans %}
+{% endblock %}
+{% block page_title %}
+  {% verbose_name_object model as widget_title %}
+  {% blocktrans with widget=widget_title %}Create {{ widget }}{% endblocktrans %}
+{% endblock %}
+
+{% block content %}
+
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+
+{% endblock %}
diff --git a/aleksis/core/templates/core/dashboard_widget/edit.html b/aleksis/core/templates/core/dashboard_widget/edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..64dfe0eed64c36257fed529475f355d0ee2e8fc0
--- /dev/null
+++ b/aleksis/core/templates/core/dashboard_widget/edit.html
@@ -0,0 +1,23 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load material_form i18n data_helpers %}
+
+{% block browser_title %}
+  {% verbose_name_object object as widget_title %}
+  {% blocktrans with widget=widget_title %}Edit {{ widget }}{% endblocktrans %}
+{% endblock %}
+{% block page_title %}
+  {% verbose_name_object object as widget_title %}
+  {% blocktrans with widget=widget_title %}Edit {{ widget }}{% endblocktrans %}
+{% endblock %}
+
+{% block content %}
+
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+
+{% endblock %}
diff --git a/aleksis/core/templates/core/dashboard_widget/list.html b/aleksis/core/templates/core/dashboard_widget/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..ab384e8620f7c6499adb3e663fdb98e4eb598b2c
--- /dev/null
+++ b/aleksis/core/templates/core/dashboard_widget/list.html
@@ -0,0 +1,22 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n data_helpers %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Dashboard widgets{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Dashboard widgets{% endblocktrans %}{% endblock %}
+
+{% block content %}
+
+  {% for ct, model in widget_types %}
+    <a class="btn green waves-effect waves-light" href="{% url 'create_dashboard_widget' ct.app_label ct.model  %}">
+      <i class="material-icons left">add</i>
+      {% verbose_name_object model as widget_name %}
+      {% blocktrans with name=widget_name %}Create {{ name }}{% endblocktrans %}
+    </a>
+  {% endfor %}
+
+  {% render_table table %}
+{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 6c989ddf7cac9fab6f99e8b309905f45a4aeebde..9061962e6a65c5ae40493413c4564b5e1fedec59 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -154,6 +154,22 @@ urlpatterns = [
         name="preferences_group",
     ),
     path("health/", include(health_urls)),
+    path("dashboard_widgets/", views.DashboardWidgetListView.as_view(), name="dashboard_widgets"),
+    path(
+        "dashboard_widgets/<int:pk>/edit/",
+        views.DashboardWidgetEditView.as_view(),
+        name="edit_dashboard_widget",
+    ),
+    path(
+        "dashboard_widgets/<int:pk>/delete/",
+        views.DashboardWidgetDeleteView.as_view(),
+        name="delete_dashboard_widget",
+    ),
+    path(
+        "dashboard_widgets/<str:app>/<str:model>/new/",
+        views.DashboardWidgetCreateView.as_view(),
+        name="create_dashboard_widget",
+    ),
 ]
 
 # Serve static files from STATIC_ROOT to make it work with runserver
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 6969d41488e3d352084f1734f123094445b3bb74..d34aa8ae81cefb821f25c2b628ed54cc1dce5563 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -1,9 +1,11 @@
-from typing import Optional
+from typing import Any, Dict, Optional, Type
 
 from django.apps import apps
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import PermissionDenied
 from django.core.paginator import Paginator
+from django.forms.models import BaseModelForm, modelform_factory
 from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse_lazy
@@ -36,7 +38,7 @@ from .forms import (
     SchoolTermForm,
     SitePreferenceForm,
 )
-from .mixins import AdvancedCreateView, AdvancedEditView
+from .mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
 from .models import (
     AdditionalField,
     Announcement,
@@ -54,6 +56,7 @@ from .registries import (
 )
 from .tables import (
     AdditionalFieldsTable,
+    DashboardWidgetTable,
     GroupsTable,
     GroupTypesTable,
     PersonsTable,
@@ -699,3 +702,75 @@ def delete_group_type(request: HttpRequest, id_: int) -> HttpResponse:
     messages.success(request, _("The group type has been deleted."))
 
     return redirect("group_types")
+
+
+class DashboardWidgetListView(SingleTableView, PermissionRequiredMixin):
+    """Table of all dashboard widgets."""
+
+    model = DashboardWidget
+    table_class = DashboardWidgetTable
+    permission_required = "core.view_dashboardwidget"
+    template_name = "core/dashboard_widget/list.html"
+
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        context = super().get_context_data(**kwargs)
+        context["widget_types"] = [
+            (ContentType.objects.get_for_model(m, False), m)
+            for m in DashboardWidget.__subclasses__()
+        ]
+        return context
+
+
+@method_decorator(never_cache, name="dispatch")
+class DashboardWidgetEditView(AdvancedEditView, PermissionRequiredMixin):
+    """Edit view for dashboard widgets."""
+
+    def get_form_class(self) -> Type[BaseModelForm]:
+        return modelform_factory(self.object.__class__, fields=self.fields)
+
+    model = DashboardWidget
+    fields = "__all__"
+    permission_required = "core.edit_dashboardwidget"
+    template_name = "core/dashboard_widget/edit.html"
+    success_url = reverse_lazy("dashboard_widgets")
+    success_message = _("The dashboard widget has been saved.")
+
+
+@method_decorator(never_cache, name="dispatch")
+class DashboardWidgetCreateView(AdvancedCreateView, PermissionRequiredMixin):
+    """Create view for dashboard widgets."""
+
+    def get_model(self, request, *args, **kwargs):
+        app_label = kwargs.get("app")
+        model = kwargs.get("model")
+        ct = get_object_or_404(ContentType, app_label=app_label, model=model)
+        return ct.model_class()
+
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        context = super().get_context_data(**kwargs)
+        context["model"] = self.model
+        return context
+
+    def get(self, request, *args, **kwargs):
+        self.model = self.get_model(request, *args, **kwargs)
+        return super().get(request, *args, **kwargs)
+
+    def post(self, request, *args, **kwargs):
+        self.model = self.get_model(request, *args, **kwargs)
+        return super().post(request, *args, **kwargs)
+
+    fields = "__all__"
+    permission_required = "core.add_dashboardwidget"
+    template_name = "core/dashboard_widget/create.html"
+    success_url = reverse_lazy("dashboard_widgets")
+    success_message = _("The dashboard widget has been created.")
+
+
+class DashboardWidgetDeleteView(PermissionRequiredMixin, AdvancedDeleteView):
+    """Delete view for dashboard widgets."""
+
+    model = DashboardWidget
+    permission_required = "core.delete_dashboardwidget"
+    template_name = "core/pages/delete.html"
+    success_url = reverse_lazy("dashboard_widgets")
+    success_message = _("The dashboard widget has been deleted.")