diff --git a/assets/style.css b/assets/style.css
index 6591942e96a5052a57029a60bb9f50b55eee0434..3a8e737bef643b83034092334388ac75feecfe0b 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -55,9 +55,21 @@
   font-weight: 700;
 }
 
-.repository-header nav,
+.repository-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 0.5rem 2rem;
+}
+
 .repository-header h1 {
   margin-block: 0.5rem;
+}
+
+.repository-header nav {
+  justify-content: flex-end;
+  font-size: 0.875rem;
 }
 
 .repository-toolbar {
@@ -73,7 +85,7 @@
 
 .repository-summary-layout {
   display: grid;
-  grid-template-columns: minmax(0, 2fr) minmax(14rem, 1fr);
+  grid-template-columns: minmax(0, 1.7fr) minmax(23rem, 1fr);
   gap: 2rem;
   align-items: start;
 }
@@ -83,18 +95,56 @@
   margin-bottom: 2rem;
 }
 
-.repository-sidebar > section {
+.repository-panel {
   padding: 1rem;
   border: 1px solid GrayText;
   border-radius: 0.25rem;
 }
 
-.repository-sidebar h2 {
+.repository-panel > h2:first-child {
   margin-top: 0;
+}
+
+.repository-file-panel {
+  padding: 0;
+  overflow: hidden;
+}
+
+.repository-file-panel > h2 {
+  margin: 0;
+  padding: 1rem;
+  border-bottom: 1px solid GrayText;
+}
+
+.repository-file-panel th,
+.repository-file-panel td {
+  padding: 0.6rem 1rem;
+}
+
+.repository-file-panel tbody tr:last-child > * {
+  border-bottom: 0;
 }
 
 .repository-file-list th:first-child {
   width: 100%;
+}
+
+.repository-sidebar dd {
+  overflow-x: auto;
+  overflow-wrap: normal;
+  white-space: nowrap;
+}
+
+.repository-recent-list {
+  padding-left: 1.5rem;
+}
+
+.repository-recent-list li {
+  margin-block: 0.5rem;
+}
+
+.repository-readme {
+  margin-block: 2rem;
 }
 
 .site-header nav,
diff --git a/src/http/public.rs b/src/http/public.rs
index 55bf5641ed98354e9dd2846f7091d64209de1784..f1ddda9ecb4e39e688b56cff2262f9bc64d97698 100644
--- a/src/http/public.rs
+++ b/src/http/public.rs
@@ -38,7 +38,8 @@
 use super::{PublicWebConfig, RequestActor, RequestId, WebState, render_error_with_auth};
 
 const MAX_HISTORY_COMMITS: usize = 10_000;
-const MAX_SUMMARY_COMMITS: usize = 10;
+const MAX_SUMMARY_COMMITS: usize = 3;
+const MAX_SUMMARY_TAGS: usize = 3;
 const COMMITS_PER_PAGE: usize = 100;
 const MAX_SEARCH_QUERY_BYTES: usize = 256;
 
@@ -436,6 +437,26 @@
                     .iter()
                     .find(|reference| reference.name == default_branch.as_bytes())
                     .map(|reference| reference.target);
+                let mut tags = references
+                    .iter()
+                    .filter(|reference| reference.name.starts_with(b"refs/tags/"))
+                    .filter_map(|reference| {
+                        let target = reference.peeled.unwrap_or(reference.target);
+                        let commit = service.commit(target, &cancellation).ok()?;
+                        Some(RepositorySummaryTag {
+                            name: reference.name[b"refs/tags/".len()..].to_vec(),
+                            target,
+                            committed_at: commit.committed_at,
+                        })
+                    })
+                    .collect::<Vec<_>>();
+                tags.sort_by(|left, right| {
+                    right
+                        .committed_at
+                        .cmp(&left.committed_at)
+                        .then_with(|| right.name.cmp(&left.name))
+                });
+                tags.truncate(MAX_SUMMARY_TAGS);
                 let (history, readme, entries) = match head {
                     Some(head) => (
                         service.history(head, &cancellation)?,
@@ -452,6 +473,7 @@
                         default_branch,
                         head,
                         history,
+                        tags,
                         readme: readme.map(|readme| (readme.path, readme.blob.data)),
                         entries,
                     },
@@ -1393,6 +1415,7 @@
     readme_html: RenderedMarkdown,
     readme_binary: bool,
     history: Vec<CommitView>,
+    tags: Vec<TagView>,
     entries: Vec<TreeView>,
     blob_content: String,
     blob_binary: bool,
@@ -1420,6 +1443,7 @@
     default_branch: String,
     head: Option<ObjectId>,
     history: Vec<CommitInfo>,
+    tags: Vec<RepositorySummaryTag>,
     readme: Option<(Vec<u8>, Vec<u8>)>,
     entries: Vec<TreeEntryInfo>,
 }
@@ -1448,6 +1472,7 @@
             readme_html: RenderedMarkdown::default(),
             readme_binary: false,
             history: Vec::new(),
+            tags: Vec::new(),
             entries: Vec::new(),
             blob_content: String::new(),
             blob_binary: false,
@@ -1488,6 +1513,15 @@
             .into_iter()
             .take(MAX_SUMMARY_COMMITS)
             .map(CommitView::from)
+            .collect();
+        page.tags = summary
+            .tags
+            .into_iter()
+            .map(|tag| TagView {
+                name: display_bytes(&tag.name),
+                id: tag.target.to_string(),
+                committed_at: tag.committed_at,
+            })
             .collect();
         if let Some(head) = summary.head {
             page.entries = summary
@@ -1692,6 +1726,18 @@
         }
         page
     }
+}
+
+struct RepositorySummaryTag {
+    name: Vec<u8>,
+    target: ObjectId,
+    committed_at: i64,
+}
+
+struct TagView {
+    name: String,
+    id: String,
+    committed_at: i64,
 }
 
 #[derive(Default)]
diff --git a/templates/repository.html b/templates/repository.html
index 525b73c4631bf37ef71394b193dec09b11cbcf63..c9b763fe942c0e50fbbfc3992afddd2250a5a85c 100644
--- a/templates/repository.html
+++ b/templates/repository.html
@@ -22,7 +22,7 @@
   <div class="repository-summary-layout">
   <div class="repository-summary-main">
 {% if has_head %}
-  <section aria-labelledby="files-heading">
+  <section class="repository-panel repository-file-panel" aria-labelledby="files-heading">
     <h2 id="files-heading">Files</h2>
     <div class="table-scroll"><table class="repository-file-list">
       <thead><tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col">Object</th></tr></thead>
@@ -37,31 +37,12 @@
       </tbody>
     </table></div>
   </section>
-  <section aria-labelledby="history-heading">
-    <h2 id="history-heading">Recent commits</h2>
-    <ol class="commit-list">
-{% for item in history %}
-      <li><a href="/{{ owner }}/{{ repository }}/commit/{{ item.id }}" aria-label="Commit {{ item.id }}"><code title="{{ item.id }}">{{ item.id|short_id }}</code></a> {{ item.summary }} — {{ item.author_name }}</li>
-{% endfor %}
-    </ol>
-    <p><a href="/{{ owner }}/{{ repository }}/commits">View all commits</a></p>
-  </section>
-{% if has_readme %}
-  <section aria-labelledby="readme-heading">
-    <h2 id="readme-heading">{{ readme_path }}</h2>
-{% if readme_binary %}
-    <p>Binary README content cannot be shown.</p>
-{% else %}
-    <div class="markdown">{{ readme_html|safe }}</div>
-{% endif %}
-  </section>
-{% endif %}
 {% else %}
   <p>This repository has no commits.</p>
 {% endif %}
   </div>
   <aside class="repository-sidebar" aria-label="Repository information">
-  <section aria-labelledby="about-heading">
+  <section class="repository-panel" aria-labelledby="about-heading">
     <h2 id="about-heading">About</h2>
 {% if description != "" %}
     <p class="repository-description">{{ description }}</p>
@@ -70,15 +51,50 @@
 {% endif %}
     <p>Created <time datetime="{{ created_at }}">{{ created_at|human_time }}</time></p>
   </section>
-  <section aria-labelledby="clone-heading">
+  <section class="repository-panel" aria-labelledby="clone-heading">
     <h2 id="clone-heading">Clone</h2>
     <dl>
       <dt>HTTP</dt><dd><code>{{ http_clone_url }}</code></dd>
       <dt>SSH</dt><dd><code>{{ ssh_clone_url }}</code></dd>
     </dl>
   </section>
+  <section class="repository-panel" aria-labelledby="recent-heading">
+    <h2 id="recent-heading">Recent</h2>
+    <h3>Commits</h3>
+{% if history.is_empty() %}
+    <p>No commits are available.</p>
+{% else %}
+    <ol class="repository-recent-list">
+{% for item in history %}
+      <li><a href="/{{ owner }}/{{ repository }}/commit/{{ item.id }}" aria-label="Commit {{ item.id }}"><code title="{{ item.id }}">{{ item.id|short_id }}</code></a> {{ item.summary }}</li>
+{% endfor %}
+    </ol>
+    <p><a href="/{{ owner }}/{{ repository }}/commits">View all commits</a></p>
+{% endif %}
+    <h3>Tags</h3>
+{% if tags.is_empty() %}
+    <p>No tags are available.</p>
+{% else %}
+    <ul class="repository-recent-list">
+{% for tag in tags %}
+      <li><a href="/{{ owner }}/{{ repository }}/commit/{{ tag.id }}">{{ tag.name }}</a> · <time datetime="{{ tag.committed_at }}">{{ tag.committed_at|human_time }}</time></li>
+{% endfor %}
+    </ul>
+    <p><a href="/{{ owner }}/{{ repository }}/refs">View all refs</a></p>
+{% endif %}
+  </section>
   </aside>
   </div>
+{% if has_readme %}
+  <section class="repository-panel repository-readme" aria-labelledby="readme-heading">
+    <h2 id="readme-heading">{{ readme_path }}</h2>
+{% if readme_binary %}
+    <p>Binary README content cannot be shown.</p>
+{% else %}
+    <div class="markdown">{{ readme_html|safe }}</div>
+{% endif %}
+  </section>
+{% endif %}
 {% endif %}
 
 {% if page_kind == "commits" %}
diff --git a/tests/public_routes.rs b/tests/public_routes.rs
index 2205df793b20c5d729eff857c7dcc3043756b5aa..393a5b099ec0140a4b47eb6b55cf7989637ef600 100644
--- a/tests/public_routes.rs
+++ b/tests/public_routes.rs
@@ -116,6 +116,9 @@
         assert!(summary_text.contains("ssh://tit.example:2222/alice/example"));
         assert!(summary_text.contains(&fixture.head));
         assert!(summary_text.contains("<div class=\"repository-summary-layout\">"));
+        assert!(summary_text.contains(
+            "</aside>\n  </div>\n\n  <section class=\"repository-panel repository-readme\""
+        ));
         assert!(summary_text.contains("<strong>main</strong>"));
         assert!(summary_text.contains(&format!(
             "href=\"/alice/example/blob/{}/README.md\"",
@@ -146,12 +149,11 @@
         assert!(summary_text.contains("/alice/example/rss.xml"));
         assert!(summary_text.contains("/alice/example/search"));
         assert!(summary_text.contains("/alice/example/commits\">View all commits</a>"));
-        assert_eq!(
-            summary_text
-                .matches("<li><a href=\"/alice/example/commit/")
-                .count(),
-            10
-        );
+        assert!(summary_text.contains(">v0.4</a>"));
+        assert!(summary_text.contains(">v0.3</a>"));
+        assert!(summary_text.contains(">v0.2</a>"));
+        assert!(!summary_text.contains(">v0.1</a>"));
+        assert_eq!(summary_text.matches("aria-label=\"Commit ").count(), 3);
         assert!(!summary_text.contains("Object format"));
 
         let commits = request(server.address(), "GET", "/alice/example/commits", &[], &[]);
@@ -1688,6 +1690,20 @@
         .expect("update the text file");
         commit_all(&worktree, "<script>alert(3)</script>");
         let head = rev_parse(&worktree, "HEAD");
+        for (tag, target) in [
+            ("v0.1", parent.as_str()),
+            ("v0.2", "HEAD~2"),
+            ("v0.3", "HEAD~1"),
+            ("v0.4", "HEAD"),
+        ] {
+            run(Command::new("git").arg("-C").arg(&worktree).args([
+                "-c",
+                "tag.gpgSign=false",
+                "tag",
+                tag,
+                target,
+            ]));
+        }
         run(Command::new("git")
             .arg("-C")
             .arg(&worktree)
@@ -1710,6 +1726,12 @@
             .args(["push", "-q"])
             .arg(&bare)
             .args(["main", "feature"]));
+        run(Command::new("git")
+            .arg("-C")
+            .arg(&worktree)
+            .args(["push", "-q"])
+            .arg(&bare)
+            .arg("--tags"));
 
         let database = instance.path().join(store::DATABASE_FILE);
         let mut store = Store::open(&database).expect("open the fixture database");
