diff --git a/district/management/commands/octopus_people_import.py b/district/management/commands/octopus_people_import.py
index 48ee4ca9c9c86a90c2a17ce9e926c442495f705b..330a0613db2f645828925d3faea69be12cbf43b4 100644
--- a/district/management/commands/octopus_people_import.py
+++ b/district/management/commands/octopus_people_import.py
@@ -52,9 +52,10 @@ class Command(BaseCommand):
 
         for person_page in DistrictManualOctopusPersonPage.objects.all():
             import_manual_person.delay(
-                person_page.id, (
+                person_page.id,
+                (
                     person_page.root_page.image_collection_id
                     if hasattr(person_page.root_page, "image_collection_id")
                     else 0
-                )
+                ),
             )
diff --git a/district/migrations/0319_auto_20250508_1642.py b/district/migrations/0319_auto_20250508_1642.py
index 3a9cbf538e5e3262e67a16f8b040d8fd07640155..2d5382061581d6777d89592b8522b8423863e8c9 100644
--- a/district/migrations/0319_auto_20250508_1642.py
+++ b/district/migrations/0319_auto_20250508_1642.py
@@ -4,13 +4,12 @@ from django.db import migrations
 
 
 def add_district_search_pages(apps, schema_editor):
-    from district.models import DistrictHomePage
-    from district.models import DistrictSearchPage
+    from district.models import DistrictHomePage, DistrictSearchPage
 
     for home_page in DistrictHomePage.objects.all():
         if DistrictSearchPage.objects.descendant_of(home_page).exists():
             unlive_page = DistrictSearchPage.objects.descendant_of(home_page).first()
-            
+
             if unlive_page is None:
                 continue
 
@@ -18,7 +17,7 @@ def add_district_search_pages(apps, schema_editor):
                 unlive_page.save_revision().publish()
 
             continue
-        
+
         search_page = DistrictSearchPage(
             title="Vyhledávací stránka",
             slug="search",
@@ -31,11 +30,8 @@ def add_district_search_pages(apps, schema_editor):
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
-        ('district', '0318_districthomepage_ecomail_newsletter_list_source'),
+        ("district", "0318_districthomepage_ecomail_newsletter_list_source"),
     ]
 
-    operations = [
-        migrations.RunPython(add_district_search_pages)
-    ]
+    operations = [migrations.RunPython(add_district_search_pages)]
diff --git a/shared/models/main.py b/shared/models/main.py
index 817293efff587c05249e70c9d9a735523b9f503e..a9e5279d3aa04d42a9e81099b6b5e0936df13f85 100644
--- a/shared/models/main.py
+++ b/shared/models/main.py
@@ -1,5 +1,6 @@
 import datetime
 import logging
+import random
 from collections import namedtuple
 from enum import Enum
 from functools import cached_property, reduce
@@ -321,10 +322,9 @@ class PageInMenuMixin(Page):
     def get_menu_title(self, parent_instance=None) -> str:
         instance = self if parent_instance is None else parent_instance
 
-        menu_iterator = (
-            getattr(getattr(instance, "root_page", None), "menu", None)
-            or getattr(instance, "menu", [])
-        )
+        menu_iterator = getattr(
+            getattr(instance, "root_page", None), "menu", None
+        ) or getattr(instance, "menu", [])
 
         for menu in menu_iterator:
             if menu.block_type == "menu_item":
@@ -1723,6 +1723,87 @@ class MainArticlePageMixin(
 
     ### END Relations
 
+    ### BEGIN Custom serving
+
+    def serve(self, request, *args, **kwargs):
+        NUM_ARTICLES = 4
+
+        get_related_articles = request.GET.get("get_related_articles")
+
+        all_articles_in_same_web = (
+            type(self.specific)
+            .objects.filter(~Q(id=self.specific.id))
+            .live()
+            .descendant_of(self.root_page.articles_page)
+        )
+
+        if get_related_articles:
+            # Make a while True loop here so we can break whenever
+            # instead of making nested if-else statements.
+
+            while True:
+                ### Enough with same tag in this web
+                articles_with_same_tags = list(
+                    all_articles_in_same_web.filter(
+                        tags__in=self.get_tags,
+                    ).all()
+                )
+
+                picked_articles = []
+
+                if len(articles_with_same_tags) == NUM_ARTICLES:
+                    picked_articles = random.sample(
+                        articles_with_same_tags,
+                        min(NUM_ARTICLES, len(articles_with_same_tags)),
+                    )
+                    break
+
+                ### Enough in this web, just without same tags
+                needed_extra_articles_num = NUM_ARTICLES - len(articles_with_same_tags)
+
+                articles_without_same_tags = list(all_articles_in_same_web.all())
+                picked_articles_without_same_tags = random.sample(
+                    articles_without_same_tags,
+                    min(needed_extra_articles_num, len(articles_without_same_tags)),
+                )
+
+                picked_articles = (
+                    articles_with_same_tags + picked_articles_without_same_tags
+                )
+
+                if len(picked_articles) == NUM_ARTICLES:
+                    break
+                else:
+                    ### Use MainArticlePages to supplement if there aren't even enough articles
+                    ### in this web. If there are no MainArticlePages, we're in a testing env
+                    ### where it doesn't matter much.
+                    from main.models import MainArticlePage
+
+                    needed_supplementary_articles_num = NUM_ARTICLES - len(
+                        picked_articles
+                    )
+                    main_web_articles = list(
+                        MainArticlePage.objects.all()[
+                            : needed_supplementary_articles_num - 1
+                        ]
+                    )
+
+                    picked_articles += main_web_articles
+
+                # Always break here so this only loops once no matter what,
+                # even if for some reason conditions aren't met.
+                break
+
+            return render(
+                request,
+                "styleguide2/includes/organisms/articles/article_links_block.html",
+                {"articles": picked_articles},
+            )
+
+        return super().serve(request, *args, **kwargs)
+
+    ### END Custom serving
+
     ### BEGIN Pages
 
     @property
diff --git a/shared/templates/styleguide2/article_page.html b/shared/templates/styleguide2/article_page.html
index b69c07599fad0129a285cd600f51cbc6d7dafbe9..5608a4fbd67861b2194e179517d3d12b1853566d 100644
--- a/shared/templates/styleguide2/article_page.html
+++ b/shared/templates/styleguide2/article_page.html
@@ -28,12 +28,49 @@
       {% include_block block %}
     {% endfor %}
 
-    <div class="flex justify-start">
-      {% include 'styleguide2/includes/atoms/buttons/round_button.html' with url=page.root_page.articles_page.url text='Zpět na aktuality' show_arrow_on_hover=True %}
+    <div class="flex justify-start gap-4">
+      {% include 'styleguide2/includes/atoms/buttons/round_button.html' with classes="bg-pirati-yellow text-black" fill="black" id="similar-articles" text='Související články' show_arrow_on_hover=True %}
+      {% include 'styleguide2/includes/atoms/buttons/round_button.html' with url=page.root_page.articles_page.url text='Zpět na seznam aktualit' show_arrow_on_hover=True %}
     </div>
+
+    <div id="similar-articles-container"></div>
   </div>
 </main>
 
+<script>
+  let similarArticlesOpen = false;
+
+  document.getElementById("similar-articles").addEventListener(
+    "click",
+    async function (event) {
+      const container = document.getElementById("similar-articles-container");
+
+      if (!similarArticlesOpen) {
+        this.disabled = true;
+
+        const articlesResponse = await fetch("./?get_related_articles=1")
+
+        if (!articlesResponse.ok) {
+          throw new Error(`Could not get articles: status ${articlesResponse.status}. Check network logs`);
+          similarArticlesOpen = !similarArticlesOpen;
+        }
+
+        container.innerHTML = await articlesResponse.text();
+        container.style.display = "block";
+
+        this.disabled = false;
+        document.getElementById(`${this.id}-inner-text`).innerHTML = "Zavřít související články";
+        this.scrollIntoView();
+      } else {
+        container.style.display = "none";
+        document.getElementById(`${this.id}-inner-text`).innerHTML = "Související články";
+      }
+
+      similarArticlesOpen = !similarArticlesOpen;
+    }
+  )
+</script>
+
 {% include 'styleguide2/includes/organisms/main_section/newsletter_section.html' %}
 
 {% endblock %}
diff --git a/shared/templates/styleguide2/includes/organisms/articles/article_links_block.html b/shared/templates/styleguide2/includes/organisms/articles/article_links_block.html
index aeb85444f5ae424c537325f423998608c51ddffe..3627f97fa922055ceb5b87793d26326e168bb5d0 100644
--- a/shared/templates/styleguide2/includes/organisms/articles/article_links_block.html
+++ b/shared/templates/styleguide2/includes/organisms/articles/article_links_block.html
@@ -1,3 +1,14 @@
+{# Allow either "articles" or "self.articles" to be used in the same way. #}
+{# TODO Make this cleaner #}
+
+{% if articles|length != 0 %}
+  <div class="grid grid-cols-1 md:grid-cols-2 gap-12">
+    {% for article in articles %}
+      {% include 'styleguide2/includes/molecules/articles/article_title_preview.html' %}
+    {% endfor %}
+  </div>
+{% endif %}
+
 {% if self.articles|length != 0 %}
   <div class="grid grid-cols-1 md:grid-cols-2 gap-12">
     {% for article in self.articles %}
diff --git a/uniweb/migrations/0138_auto_20250508_1752.py b/uniweb/migrations/0138_auto_20250508_1752.py
index 2fefb91dec57e4149060d0bed303f1ae8fa95a01..953f53cafaa0cb26b2242ca46baf40b67f8df9a9 100644
--- a/uniweb/migrations/0138_auto_20250508_1752.py
+++ b/uniweb/migrations/0138_auto_20250508_1752.py
@@ -4,13 +4,12 @@ from django.db import migrations
 
 
 def add_uniweb_search_pages(apps, schema_editor):
-    from uniweb.models import UniwebHomePage
-    from uniweb.models import UniwebSearchPage
+    from uniweb.models import UniwebHomePage, UniwebSearchPage
 
     for home_page in UniwebHomePage.objects.all():
         if UniwebSearchPage.objects.descendant_of(home_page).exists():
             unlive_page = UniwebSearchPage.objects.descendant_of(home_page).first()
-            
+
             if unlive_page is None:
                 continue
 
@@ -18,7 +17,7 @@ def add_uniweb_search_pages(apps, schema_editor):
                 unlive_page.save_revision().publish()
 
             continue
-        
+
         search_page = UniwebSearchPage(
             title="Vyhledávací stránka",
             slug="search",
@@ -31,11 +30,8 @@ def add_uniweb_search_pages(apps, schema_editor):
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
-        ('uniweb', '0137_uniwebformpage_submission_button_text'),
+        ("uniweb", "0137_uniwebformpage_submission_button_text"),
     ]
 
-    operations = [
-        migrations.RunPython(add_uniweb_search_pages)
-    ]
+    operations = [migrations.RunPython(add_uniweb_search_pages)]