diff --git a/.gitmodules b/.gitmodules
index 4ae5678366d5787af63d81dd7973038fcd320f13..473e630164971d3e1620a9d67eb6810d839c441e 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -10,3 +10,7 @@
 	path = apps/official/AlekSIS-App-Exlibris
 	url = https://edugit.org/AlekSIS/AlekSIS-App-Exlibris
 	ignore = untracked
+[submodule "apps/official/AlekSIS-App-DashboardFeeds"]
+	path = apps/official/AlekSIS-App-DashboardFeeds
+	url = https://edugit.org/AlekSIS/AlekSIS-App-DashboardFeeds.git
+	ignore = untracked
diff --git a/aleksis/core/migrations/0009_dashboard_widget.py b/aleksis/core/migrations/0009_dashboard_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..b57c272b4cc8501e41b5ffaf9f2ec3d796795021
--- /dev/null
+++ b/aleksis/core/migrations/0009_dashboard_widget.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.0.2 on 2020-01-29 16:45
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('core', '0008_rename_fields_notification_activity'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DashboardWidget',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=150, verbose_name='Widget Title')),
+                ('active', models.BooleanField(blank=True, verbose_name='Activate Widget')),
+                ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_core.dashboardwidget_set+', to='contenttypes.ContentType')),
+            ],
+            options={
+                'verbose_name': 'Dashboard Widget',
+                'verbose_name_plural': 'Dashboard Widgets',
+            },
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index ecf66c8ed4e0d15ff46cd9c0ee239bfb84bc4737..8995d6d578095fe60713fe1aa2a0a5bb2caaed64 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -6,6 +6,7 @@ from django.db import models
 from django.utils.translation import ugettext_lazy as _
 from image_cropping import ImageCropField, ImageRatioField
 from phonenumber_field.modelfields import PhoneNumberField
+from polymorphic.models import PolymorphicModel
 
 from .mixins import ExtensibleModel
 from .util.notifications import send_notification
@@ -227,3 +228,46 @@ class Notification(models.Model):
     class Meta:
         verbose_name = _("Notification")
         verbose_name_plural = _("Notifications")
+
+
+class DashboardWidget(PolymorphicModel):
+    """ Base class for dashboard widgets on the index page
+
+    To implement a widget, add a model that subclasses DashboardWidget, sets the template
+    and implements the get_context method to return a dictionary to be passed as context
+    to the template.
+
+    If your widget does not add any database fields, you should mark it as a proxy model.
+
+    Example::
+    
+      from aleksis.core.models import DashboardWidget
+
+      class MyWidget(DhasboardWIdget):
+          template = "myapp/widget.html"
+
+          def get_context(self):
+              context = {"some_content": "foo"}
+              return context
+
+          class Meta:
+              proxy = True
+    """
+
+    template = None
+
+    title = models.CharField(max_length=150, verbose_name=_("Widget Title"))
+    active = models.BooleanField(blank=True, verbose_name=_("Activate Widget"))
+
+    def get_context(self):
+        raise NotImplementedError("A widget subclass needs to implement the get_context method.")
+
+    def get_template(self):
+        return self.template
+
+    def __str__(self):
+        return self.title
+
+    class Meta:
+        verbose_name = _("Dashboard Widget")
+        verbose_name_plural = _("Dashboard Widgets")
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 46b070449c3bc6ff46e98ace323953b7f6252ac1..c314e3a7305a2c1655de57df9d4ac05220b4238a 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -51,6 +51,7 @@ INSTALLED_APPS = [
     "django.contrib.sessions",
     "django.contrib.messages",
     "django.contrib.staticfiles",
+    "polymorphic",
     "django_global_request",
     "settings_context_processor",
     "sass_processor",
diff --git a/aleksis/core/static/js/include_ajax_live.js b/aleksis/core/static/js/include_ajax_live.js
new file mode 100644
index 0000000000000000000000000000000000000000..3a4794bad9881ebe502ea7786b8c0d2a740e65f0
--- /dev/null
+++ b/aleksis/core/static/js/include_ajax_live.js
@@ -0,0 +1,35 @@
+const asyncIntervals = [];
+
+const runAsyncInterval = async (cb, interval, intervalIndex) => {
+  await cb();
+  if (asyncIntervals[intervalIndex]) {
+    setTimeout(() => runAsyncInterval(cb, interval, intervalIndex), interval);
+  }
+};
+
+const setAsyncInterval = (cb, interval) => {
+  if (cb && typeof cb === "function") {
+    const intervalIndex = asyncIntervals.length;
+    asyncIntervals.push(true);
+    runAsyncInterval(cb, interval, intervalIndex);
+    return intervalIndex;
+  } else {
+    throw new Error('Callback must be a function');
+  }
+};
+
+const clearAsyncInterval = (intervalIndex) => {
+  if (asyncIntervals[intervalIndex]) {
+    asyncIntervals[intervalIndex] = false;
+  }
+};
+
+let live_load_interval = setAsyncInterval(async () => {
+  console.log('fetching new data');
+  const promise = new Promise((resolve) => {
+    $('#live_load').load(window.location.pathname + " #live_load");
+    resolve(1);
+  });
+  await promise;
+  console.log('data fetched successfully');
+}, 15000);
diff --git a/aleksis/core/templates/core/index.html b/aleksis/core/templates/core/index.html
index 05875979203262f7f8818db06400da609679c891..22d0d4013929f402276f9a960450c4f2f99725e8 100644
--- a/aleksis/core/templates/core/index.html
+++ b/aleksis/core/templates/core/index.html
@@ -1,5 +1,5 @@
 {% extends 'core/base.html' %}
-{% load i18n %}
+{% load i18n static dashboard %}
 
 {% block browser_title %}{% blocktrans %}Home{% endblocktrans %}{% endblock %}
 
@@ -24,6 +24,14 @@
       </div>
     {% endfor %}
 
+    <div class="row" id="live_load">
+        {% for widget in widgets %}
+          <div class="col s12 m12 l6 xl4">
+              {% include_widget widget %}
+          </div>
+        {% endfor %}
+    </div>
+
     <div class="row">
       <div class="col s12 m6">
         <h5>{% blocktrans %}Last activities{% endblocktrans %}</h5>
@@ -77,4 +85,6 @@
       </div>
     </div>
   {% endif %}
+
+  <script type="text/javascript" src="{% static "js/include_ajax_live.js" %}"></script>
 {% endblock %}
diff --git a/aleksis/core/templatetags/dashboard.py b/aleksis/core/templatetags/dashboard.py
new file mode 100644
index 0000000000000000000000000000000000000000..b051084f88008a1b30758d46f1d91c9695a408e1
--- /dev/null
+++ b/aleksis/core/templatetags/dashboard.py
@@ -0,0 +1,13 @@
+from django.template import Library, loader
+
+register = Library()
+
+
+@register.simple_tag
+def include_widget(widget) -> dict:
+    """ Render a template with context from a defined widget """
+
+    template = loader.get_template(widget.get_template())
+    context = widget.get_context()
+
+    return template.render(context)
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 63929088ba38406d0e560fcaee218404ad1462bf..6f1d4275e0fd15f2b1cd315353beb56d219342fd 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -66,4 +66,9 @@ for app_config in apps.app_configs.values():
     if not app_config.name.startswith("aleksis.apps."):
         continue
 
-    urlpatterns.append(path("app/%s/" % app_config.label, include("%s.urls" % app_config.name)))
+    try:
+        urlpatterns.append(path("app/%s/" % app_config.label, include("%s.urls" % app_config.name)))
+    except ModuleNotFoundError:
+        # Ignore exception as app just has no URLs
+        pass  # noqa
+
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index ff11482f68268d5f5d090502039d1c4be12e99a2..c610ec8abf640246355fd04e56130e15f103f483 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -16,7 +16,7 @@ from .forms import (
     EditTermForm,
     PersonsAccountsFormSet,
 )
-from .models import Activity, Group, Notification, Person, School
+from .models import Activity, Group, Notification, Person, School, DashboardWidget
 from .tables import GroupsTable, PersonsTable
 from .util import messages
 
@@ -33,6 +33,8 @@ def index(request: HttpRequest) -> HttpResponse:
     context["notifications"] = notifications
     context["unread_notifications"] = unread_notifications
 
+    context["widgets"] = DashboardWidget.objects.filter(active=True)
+
     return render(request, "core/index.html", context)
 
 
diff --git a/apps/official/AlekSIS-App-DashboardFeeds b/apps/official/AlekSIS-App-DashboardFeeds
new file mode 160000
index 0000000000000000000000000000000000000000..8207d487baa1767cff5ee43cc1706ab025bf644e
--- /dev/null
+++ b/apps/official/AlekSIS-App-DashboardFeeds
@@ -0,0 +1 @@
+Subproject commit 8207d487baa1767cff5ee43cc1706ab025bf644e
diff --git a/pyproject.toml b/pyproject.toml
index 1c3bdb77b59e270f075470b6b0dd82525d126073..99d24b842d98224ee165ce0ee0191a50a6523b01 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,7 @@ django-celery-results = {version="^1.1.2", optional=true}
 django-celery-beat = {version="^1.5.0", optional=true}
 django-celery-email = {version="^3.0.0", optional=true}
 django-jsonstore = "^0.4.1"
+django-polymorphic = "^2.1.2"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]