diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index 9297980c7cc8bbd494f6124ee914f6d542b1863f..f4599d4d0e13ac02d01fda147331a3997d0f8dd0 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -10,7 +10,15 @@ from dynamic_preferences.forms import PreferenceForm
 from material import Fieldset, Layout, Row
 
 from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm
-from .models import AdditionalField, Announcement, Group, GroupType, Person, SchoolTerm
+from .models import (
+    AdditionalField,
+    Announcement,
+    DashboardWidget,
+    Group,
+    GroupType,
+    Person,
+    SchoolTerm,
+)
 from .registries import (
     group_preferences_registry,
     person_preferences_registry,
@@ -345,3 +353,20 @@ class SchoolTermForm(ExtensibleForm):
     class Meta:
         model = SchoolTerm
         exclude = []
+
+
+class DashboardWidgetOrderForm(ExtensibleForm):
+    pk = forms.ModelChoiceField(
+        queryset=DashboardWidget.objects.all(),
+        widget=forms.HiddenInput(attrs={"class": "pk-input"}),
+    )
+    order = forms.IntegerField(initial=0, widget=forms.HiddenInput(attrs={"class": "order-input"}))
+
+    class Meta:
+        model = DashboardWidget
+        fields = []
+
+
+DashboardWidgetOrderFormSet = forms.formset_factory(
+    form=DashboardWidgetOrderForm, max_num=0, extra=0
+)
diff --git a/aleksis/core/migrations/0007_dashboard_widget_order.py b/aleksis/core/migrations/0007_dashboard_widget_order.py
new file mode 100644
index 0000000000000000000000000000000000000000..9387f4ca7c0dbb3307d2a3681f091e4bf631f847
--- /dev/null
+++ b/aleksis/core/migrations/0007_dashboard_widget_order.py
@@ -0,0 +1,35 @@
+# Generated by Django 3.1.4 on 2020-12-21 13:38
+
+import django.contrib.sites.managers
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('core', '0006_dashboard_widget_size'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DashboardWidgetOrder',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('order', models.PositiveIntegerField(verbose_name='Order')),
+                ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.person', verbose_name='Person')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
+                ('widget', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.dashboardwidget', verbose_name='Dashboard widget')),
+            ],
+            options={
+                'verbose_name': 'Dashboard widget order',
+                'verbose_name_plural': 'Dashboard widget orders',
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index c9d818daf98a7b34d1e124b9bf463b7d283d79b1..0f7a80d416cfe1dec30b57f2bf1e070e664a8966 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -234,6 +234,12 @@ class Person(ExtensibleModel):
                 years -= 1
             return years
 
+    @property
+    def dashboard_widgets(self):
+        return [
+            w.widget for w in DashboardWidgetOrder.objects.filter(person=self).order_by("order")
+        ]
+
     def save(self, *args, **kwargs):
         super().save(*args, **kwargs)
 
@@ -727,6 +733,18 @@ class DashboardWidget(PolymorphicModel, PureDjangoModel):
         verbose_name_plural = _("Dashboard Widgets")
 
 
+class DashboardWidgetOrder(ExtensibleModel):
+    widget = models.ForeignKey(
+        DashboardWidget, on_delete=models.CASCADE, verbose_name=_("Dashboard widget")
+    )
+    person = models.ForeignKey(Person, on_delete=models.CASCADE, verbose_name=_("Person"))
+    order = models.PositiveIntegerField(verbose_name=_("Order"))
+
+    class Meta:
+        verbose_name = _("Dashboard widget order")
+        verbose_name_plural = _("Dashboard widget orders")
+
+
 class CustomMenu(ExtensibleModel):
     """A custom menu to display in the footer."""
 
diff --git a/aleksis/core/static/js/edit_dashboard.js b/aleksis/core/static/js/edit_dashboard.js
new file mode 100644
index 0000000000000000000000000000000000000000..18ddaf45775bc5ff059ca2b59ddefc46ff7afd92
--- /dev/null
+++ b/aleksis/core/static/js/edit_dashboard.js
@@ -0,0 +1,22 @@
+function refreshOrder() {
+    $(".order-input").val(0);
+    $("#widgets > .col").each(function (i) {
+        const order = (i + 1) * 10;
+        let pk = $(this).attr("data-pk");
+        let sel = $("#order-form input[value=" + pk + "].pk-input").next();
+        sel.val(order);
+    })
+}
+
+$(document).ready(function () {
+    $('#not-used-widgets').sortable({
+        group: 'widgets',
+        animation: 150,
+        onEnd: refreshOrder
+    });
+    $('#widgets').sortable({
+        group: 'widgets',
+        animation: 150,
+        onEnd: refreshOrder
+    });
+});
diff --git a/aleksis/core/static/style.scss b/aleksis/core/static/style.scss
index e2ea3bbd9953477469350e1c477556947bf8f0e2..d357eea2449b4b2c1510fe9ddce1c6e6f2f26bc8 100644
--- a/aleksis/core/static/style.scss
+++ b/aleksis/core/static/style.scss
@@ -622,3 +622,7 @@ main .alert p:first-child, main .alert div:first-child {
   overflow: visible;
   width: 100%;
 }
+
+.draggable {
+  cursor: grab;
+}
diff --git a/aleksis/core/templates/core/edit_dashboard.html b/aleksis/core/templates/core/edit_dashboard.html
new file mode 100644
index 0000000000000000000000000000000000000000..a15f24bff8ebcca6a7f9b4ea081d9d8c008f3b0a
--- /dev/null
+++ b/aleksis/core/templates/core/edit_dashboard.html
@@ -0,0 +1,41 @@
+{% extends 'core/base.html' %}
+{% load i18n static dashboard any_js %}
+
+{% block browser_title %}{% blocktrans %}Edit dashboard{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Edit dashboard{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <div class="alert primary">
+    <p>
+      <i class="material-icons left">info</i>
+      On this page you can arrange your personal dashboard. You can drag any items from "Available widgets" to "Your
+      Dashboard" or change the order by moving the widgets. After you have finished, please don't forget to click on
+      "Save".
+    </p>
+  </div>
+
+  <form action="" method="post" id="order-form">
+    {% csrf_token %}
+    {{ formset.management_form }}
+    {% for form in formset %}
+      {{ form.as_p }}
+    {% endfor %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+
+  <h5>{% trans "Available widgets" %}</h5>
+  <div class="row card-panel grey lighten-3" id="not-used-widgets">
+    {% for widget in not_used_widgets %}
+      {% include "core/partials/edit_dashboard_widget.html" %}
+    {% endfor %}
+  </div>
+
+  <h5>{% trans "Your dashboard" %}</h5>
+  <div class="row card-panel grey lighten-3" id="widgets">
+    {% for widget in widgets %}
+      {% include "core/partials/edit_dashboard_widget.html" %}
+    {% endfor %}
+  </div>
+
+  <script src="{% static "js/edit_dashboard.js" %}"></script>
+{% endblock %}
diff --git a/aleksis/core/templates/core/index.html b/aleksis/core/templates/core/index.html
index dc7338e83ccdce193a3974b68bfcbeb589f6ae2a..93b80ddefffaa8cf72db56a7ef503a694f0514f5 100644
--- a/aleksis/core/templates/core/index.html
+++ b/aleksis/core/templates/core/index.html
@@ -2,13 +2,21 @@
 {% load i18n static dashboard %}
 
 {% block browser_title %}{% blocktrans %}Home{% endblocktrans %}{% endblock %}
-{% block page_title %}{{ request.site.preferences.general__title }}{% endblock %}
+{% block no_page_title %}{% endblock %}
 
 {% block extra_head %}
   {{ media }}
 {% endblock %}
 
 {% block content %}
+  <a class="btn-flat waves-effect waves-light right" href="{% url "edit_dashboard" %}">
+    <i class="material-icons left">edit</i>
+    {% trans "Edit dashboard" %}
+  </a>
+  <h4>
+    {{ request.site.preferences.general__title }}
+  </h4>
+
   {% if user.is_authenticated %}
     {% for notification in unread_notifications %}
       <div class="alert primary scale-transition">
@@ -34,6 +42,16 @@
         <div class="col s{{ widget.size_s }} m{{ widget.size_m }} l{{ widget.size_l }} xl{{ widget.size_xl }}">
           {% include_widget widget %}
         </div>
+        {% empty %}
+        <div class="col s12 grey-text center">
+          <i class="material-icons medium ">widgets</i>
+          <p class="flow-text">
+            {% blocktrans %}
+              You haven't selected any dashboard widgets. Please click on "Edit dashboard" to add widgets to your
+              personal dashboard.
+            {% endblocktrans %}
+          </p>
+        </div>
       {% endfor %}
     </div>
 
diff --git a/aleksis/core/templates/core/partials/edit_dashboard_widget.html b/aleksis/core/templates/core/partials/edit_dashboard_widget.html
new file mode 100644
index 0000000000000000000000000000000000000000..b842c0937f05c4e90a4d7337871460130fa43012
--- /dev/null
+++ b/aleksis/core/templates/core/partials/edit_dashboard_widget.html
@@ -0,0 +1,9 @@
+<div class="col draggable s{{ widget.size_s }} m{{ widget.size_m }} l{{ widget.size_l }} xl{{ widget.size_xl }}"
+     data-pk="{{ widget.pk }}">
+  <div class="card placeholder">
+    <div class="card-content">
+      <i class="material-icons left small">drag_handle</i>
+      <span class="card-title">{{ widget.title }}</span>
+    </div>
+  </div>
+</div>
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 9061962e6a65c5ae40493413c4564b5e1fedec59..0e9e20863125f0355e6bea5ba26dd5fd614aa602 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -58,6 +58,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("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"),
     path(
         "notifications/mark-read/<int:id_>",
         views.notification_mark_read,
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index d34aa8ae81cefb821f25c2b628ed54cc1dce5563..dc3ce194debff03f231de58a803d78808cb47bfd 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -12,6 +12,7 @@ from django.urls import reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.cache import never_cache
+from django.views.generic.base import View
 
 import reversion
 from django_tables2 import RequestConfig, SingleTableView
@@ -28,6 +29,7 @@ from .filters import GroupFilter, PersonFilter
 from .forms import (
     AnnouncementForm,
     ChildGroupsForm,
+    DashboardWidgetOrderFormSet,
     EditAdditionalFieldForm,
     EditGroupForm,
     EditGroupTypeForm,
@@ -43,6 +45,7 @@ from .models import (
     AdditionalField,
     Announcement,
     DashboardWidget,
+    DashboardWidgetOrder,
     Group,
     GroupType,
     Notification,
@@ -83,7 +86,7 @@ def index(request: HttpRequest) -> HttpResponse:
     announcements = Announcement.objects.at_time().for_person(request.user.person)
     context["announcements"] = announcements
 
-    widgets = DashboardWidget.objects.filter(active=True)
+    widgets = request.user.person.dashboard_widgets
     media = DashboardWidget.get_media(widgets)
 
     context["widgets"] = widgets
@@ -774,3 +777,56 @@ class DashboardWidgetDeleteView(PermissionRequiredMixin, AdvancedDeleteView):
     template_name = "core/pages/delete.html"
     success_url = reverse_lazy("dashboard_widgets")
     success_message = _("The dashboard widget has been deleted.")
+
+
+class EditDashboardView(View):
+    """View for editing dashboard widget order."""
+
+    def get(self, request):
+        context = {}
+
+        widgets = request.user.person.dashboard_widgets
+        not_used_widgets = DashboardWidget.objects.exclude(pk__in=[w.pk for w in widgets])
+        context["widgets"] = widgets
+        context["not_used_widgets"] = not_used_widgets
+
+        i = 10
+        initial = []
+        for widget in widgets:
+            initial.append({"pk": widget, "order": i})
+            i += 10
+        for widget in not_used_widgets:
+            initial.append({"pk": widget, "order": 0})
+
+        formset = DashboardWidgetOrderFormSet(
+            request.POST or None, initial=initial, prefix="widget_order"
+        )
+
+        context["formset"] = formset
+
+        if request.method == "POST" and formset.is_valid():
+            added_objects = []
+            for form in formset:
+                if not form.cleaned_data["order"]:
+                    continue
+
+                obj, created = DashboardWidgetOrder.objects.update_or_create(
+                    widget=form.cleaned_data["pk"],
+                    person=request.user.person,
+                    defaults={"order": form.cleaned_data["order"]},
+                )
+
+                added_objects.append(obj.pk)
+
+            DashboardWidgetOrder.objects.filter(person=request.user.person).exclude(
+                pk__in=added_objects
+            ).delete()
+
+            messages.success(
+                request, _("Your dashboard configuration has been saved successfully.")
+            )
+            return redirect("index")
+        return render(request, "core/edit_dashboard.html", context=context)
+
+    def post(self, request):
+        return self.get(request)