diff --git a/src/inbound/renderer/admin_ui/mod.rs b/src/inbound/renderer/admin_ui/mod.rs
index e004c7b..8b6997d 100644
--- a/src/inbound/renderer/admin_ui/mod.rs
+++ b/src/inbound/renderer/admin_ui/mod.rs
@@ -1,5 +1,7 @@
 use dioxus::prelude::*;
 
+mod server;
+
 #[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
 #[rustfmt::skip]
 enum Route {
@@ -17,6 +19,31 @@ pub fn App() -> Element {
 fn Home() -> Element {
     rsx! {
         h2 { "Hello!" }
+        SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "Loading sites..." }, SiteList {} }
+    }
+}
+
+fn SiteList() -> Element {
+    let sites = use_server_future(server::site_list)?.suspend()?;
+
+    rsx! {
+        h3 { "Sites" }
+        match &*sites.read() {
+            Ok(sites) => {
+                rsx! {
+                    ul {
+                        for site in sites {
+                            li { "{site.domain} - {site.title}" }
+                        }
+                    }
+                }
+            }
+            Err(err) => {
+                rsx! {
+                    h1 { "Error: {err}" }
+                }
+            }
+        }
     }
 }
 
@@ -28,3 +55,50 @@ fn AdminLayout() -> Element {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::outbound::{
+        repository::site::SiteMetadata,
+        services::site::{MockSiteService, SiteServiceProvider},
+    };
+
+    use super::*;
+
+    #[test]
+    fn site_list_shows_sites() {
+        let mut app = VirtualDom::new(|| {
+            rsx! {
+                SiteList {}
+            }
+        });
+
+        let mut mock_service = MockSiteService::new();
+        mock_service
+            .expect_list_sites()
+            .times(1)
+            .returning(move || {
+                Box::pin(async {
+                    Ok(vec![
+                        SiteMetadata {
+                            title: "My test website".to_string(),
+                            domain: "test.com".to_string(),
+                        },
+                        SiteMetadata {
+                            title: "Other site".to_string(),
+                            domain: "other.com".to_string(),
+                        },
+                    ])
+                })
+            });
+
+        server_context().insert(SiteServiceProvider::with(mock_service));
+
+        app.rebuild_in_place();
+        let elem_str = dioxus::ssr::render(&app);
+        assert!(elem_str.contains("My test website"));
+        assert!(elem_str.contains("test.com"));
+        assert!(elem_str.contains("Other site"));
+        assert!(elem_str.contains("other.com"));
+    }
+}
diff --git a/src/inbound/renderer/admin_ui/server.rs b/src/inbound/renderer/admin_ui/server.rs
new file mode 100644
index 0000000..10739ce
--- /dev/null
+++ b/src/inbound/renderer/admin_ui/server.rs
@@ -0,0 +1,20 @@
+use dioxus::prelude::*;
+
+use crate::domain::entities::site::SiteInfo;
+
+#[server]
+pub async fn site_list() -> Result<Vec<SiteInfo>, ServerFnError> {
+    use crate::outbound::services::site::SiteServiceProvider;
+    let FromContext(SiteServiceProvider { service }) = extract().await?;
+
+    let sites = service.list_sites().await?;
+
+    Ok(sites
+        .iter()
+        .map(|site| SiteInfo {
+            domain: site.domain.clone(),
+            title: site.title.clone(),
+            pages: vec![],
+        })
+        .collect())
+}
diff --git a/src/outbound/repository/adapters/memory/mod.rs b/src/outbound/repository/adapters/memory/mod.rs
new file mode 100644
index 0000000..30cfa95
--- /dev/null
+++ b/src/outbound/repository/adapters/memory/mod.rs
@@ -0,0 +1,139 @@
+use std::{collections::HashMap, sync::Arc};
+
+use tokio::sync::Mutex;
+
+use crate::{
+    domain::entities::site::{Page, Post},
+    outbound::repository::site::SiteMetadata,
+};
+
+mod site;
+
+struct InMemoryStoreData {
+    sites: HashMap<String, SiteMetadata>,
+    pages: HashMap<(String, String), Page>,
+    posts: HashMap<(String, String), HashMap<String, Post>>,
+}
+
+#[derive(Clone)]
+pub struct InMemoryStore {
+    data: Arc<Mutex<InMemoryStoreData>>,
+}
+
+impl InMemoryStore {
+    pub fn new() -> InMemoryStore {
+        InMemoryStore {
+            data: Arc::new(Mutex::new(InMemoryStoreData {
+                sites: HashMap::new(),
+                pages: HashMap::new(),
+                posts: HashMap::new(),
+            })),
+        }
+    }
+
+    pub async fn add_post(&self, site: &str, collection_id: &str, post_id: &str, post: Post) {
+        self.data
+            .lock()
+            .await
+            .posts
+            .entry((site.to_string(), collection_id.to_string()))
+            .or_insert_with(HashMap::new)
+            .insert(post_id.to_string(), post);
+    }
+
+    #[cfg(test)]
+    pub async fn with_test_data() -> InMemoryStore {
+        use crate::{
+            domain::entities::site::{Block, PageContent, PageInfo, Post},
+            outbound::repository::site::SiteRepository,
+        };
+
+        let store = InMemoryStore::new();
+
+        store
+            .create_site(SiteMetadata {
+                domain: "example.com".to_string(),
+                title: "Test site".to_string(),
+            })
+            .await
+            .unwrap();
+
+        store
+            .create_site(SiteMetadata {
+                domain: "other.com".to_string(),
+                title: "Other site".to_string(),
+            })
+            .await
+            .unwrap();
+
+        store
+            .set_page(
+                "example.com",
+                Page {
+                    info: PageInfo {
+                        title: "Home".to_string(),
+                        name: "/".to_string(),
+                        order: 0,
+                    },
+                    content: PageContent::Single {
+                        content: Post {
+                            blocks: vec![Block::Text {
+                                text: "Hello, world!".to_string(),
+                            }],
+                        },
+                    },
+                },
+            )
+            .await
+            .unwrap();
+
+        store
+            .set_page(
+                "example.com",
+                Page {
+                    info: PageInfo {
+                        title: "About".to_string(),
+                        name: "about".to_string(),
+                        order: 10,
+                    },
+                    content: PageContent::Single {
+                        content: Post {
+                            blocks: vec![Block::Text {
+                                text: "This is the about page.".to_string(),
+                            }],
+                        },
+                    },
+                },
+            )
+            .await
+            .unwrap();
+
+        store
+            .add_post(
+                "example.com",
+                "home_posts",
+                "post_id",
+                Post {
+                    blocks: vec![Block::Text {
+                        text: "Hello, world!".to_string(),
+                    }],
+                },
+            )
+            .await;
+
+        store
+            .add_post(
+                "example.com",
+                "home_posts",
+                "post_id2",
+                Post {
+                    blocks: vec![Block::Text {
+                        text: "Hello again!".to_string(),
+                    }],
+                },
+            )
+            .await;
+
+        store
+    }
+}
diff --git a/src/outbound/repository/adapters/memory.rs b/src/outbound/repository/adapters/memory/site.rs
similarity index 76%
rename from src/outbound/repository/adapters/memory.rs
rename to src/outbound/repository/adapters/memory/site.rs
index 8d89a57..9706612 100644
--- a/src/outbound/repository/adapters/memory.rs
+++ b/src/outbound/repository/adapters/memory/site.rs
@@ -1,146 +1,28 @@
-use std::{collections::HashMap, sync::Arc};
-
 use async_trait::async_trait;
-use tokio::sync::Mutex;
 
 use crate::{
     domain::entities::{
         cursor::{CursorOptions, Paginated},
         site::{Page, PageInfo, Post},
     },
-    outbound::repository::{
-        self,
-        site::{Result, SiteMetadata, SiteRepository},
-    },
+    outbound::repository::site::{Error, Result, SiteMetadata, SiteRepository},
 };
 
-struct InMemoryStoreData {
-    sites: HashMap<String, SiteMetadata>,
-    pages: HashMap<(String, String), Page>,
-    posts: HashMap<(String, String), HashMap<String, Post>>,
-}
-
-#[derive(Clone)]
-pub struct InMemoryStore {
-    data: Arc<Mutex<InMemoryStoreData>>,
-}
-
-impl InMemoryStore {
-    pub fn new() -> InMemoryStore {
-        InMemoryStore {
-            data: Arc::new(Mutex::new(InMemoryStoreData {
-                sites: HashMap::new(),
-                pages: HashMap::new(),
-                posts: HashMap::new(),
-            })),
-        }
-    }
-
-    pub async fn add_post(&self, site: &str, collection_id: &str, post_id: &str, post: Post) {
-        self.data
-            .lock()
-            .await
-            .posts
-            .entry((site.to_string(), collection_id.to_string()))
-            .or_insert_with(HashMap::new)
-            .insert(post_id.to_string(), post);
-    }
-
-    #[cfg(test)]
-    pub async fn with_test_data() -> InMemoryStore {
-        use crate::domain::entities::site::{Block, PageContent, Post};
-
-        let store = InMemoryStore::new();
-
-        store
-            .create_site(SiteMetadata {
-                domain: "example.com".to_string(),
-                title: "Test site".to_string(),
-            })
-            .await
-            .unwrap();
-
-        store
-            .set_page(
-                "example.com",
-                Page {
-                    info: PageInfo {
-                        title: "Home".to_string(),
-                        name: "/".to_string(),
-                        order: 0,
-                    },
-                    content: PageContent::Single {
-                        content: Post {
-                            blocks: vec![Block::Text {
-                                text: "Hello, world!".to_string(),
-                            }],
-                        },
-                    },
-                },
-            )
-            .await
-            .unwrap();
-
-        store
-            .set_page(
-                "example.com",
-                Page {
-                    info: PageInfo {
-                        title: "About".to_string(),
-                        name: "about".to_string(),
-                        order: 10,
-                    },
-                    content: PageContent::Single {
-                        content: Post {
-                            blocks: vec![Block::Text {
-                                text: "This is the about page.".to_string(),
-                            }],
-                        },
-                    },
-                },
-            )
-            .await
-            .unwrap();
-
-        store
-            .add_post(
-                "example.com",
-                "home_posts",
-                "post_id",
-                Post {
-                    blocks: vec![Block::Text {
-                        text: "Hello, world!".to_string(),
-                    }],
-                },
-            )
-            .await;
-
-        store
-            .add_post(
-                "example.com",
-                "home_posts",
-                "post_id2",
-                Post {
-                    blocks: vec![Block::Text {
-                        text: "Hello again!".to_string(),
-                    }],
-                },
-            )
-            .await;
-
-        store
-    }
-}
+use super::InMemoryStore;
 
 #[async_trait]
 impl SiteRepository for InMemoryStore {
+    async fn list_sites(&self) -> Result<Vec<SiteMetadata>> {
+        Ok(self.data.lock().await.sites.values().cloned().collect())
+    }
+
     async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> {
         self.data
             .lock()
             .await
             .sites
             .get(domain)
-            .ok_or(repository::site::Error::NotFound)
+            .ok_or(Error::NotFound)
             .cloned()
     }
 
@@ -163,7 +45,7 @@ impl SiteRepository for InMemoryStore {
     async fn create_site(&self, site: SiteMetadata) -> Result<()> {
         // Check for existing site
         if self.data.lock().await.sites.contains_key(&site.domain) {
-            return Err(repository::site::Error::Conflict);
+            return Err(Error::Conflict);
         }
 
         self.data
@@ -181,7 +63,7 @@ impl SiteRepository for InMemoryStore {
             .await
             .sites
             .remove(domain)
-            .ok_or(repository::site::Error::NotFound)
+            .ok_or(Error::NotFound)
             .map(|_| ())
     }
 
@@ -191,7 +73,7 @@ impl SiteRepository for InMemoryStore {
             .await
             .pages
             .get(&(domain.to_string(), page.to_string()))
-            .ok_or(repository::site::Error::NotFound)
+            .ok_or(Error::NotFound)
             .cloned()
     }
 
@@ -214,7 +96,7 @@ impl SiteRepository for InMemoryStore {
             .remove(&(domain.to_string(), page.to_string()));
 
         if deleted.is_none() {
-            return Err(repository::site::Error::NotFound);
+            return Err(Error::NotFound);
         }
 
         Ok(())
@@ -228,7 +110,7 @@ impl SiteRepository for InMemoryStore {
             .get(&(domain.to_string(), collection_id.to_string()))
             .and_then(|posts| posts.get(post_id))
             .cloned()
-            .ok_or(repository::site::Error::NotFound)
+            .ok_or(Error::NotFound)
     }
 
     async fn get_posts_for_collection(
@@ -243,7 +125,7 @@ impl SiteRepository for InMemoryStore {
             .await
             .posts
             .get(&(domain.to_string(), collection_id.to_string()))
-            .ok_or(repository::site::Error::NotFound)
+            .ok_or(Error::NotFound)
             .cloned()?;
 
         // Sort by post_id
@@ -300,6 +182,22 @@ mod tests {
 
     use super::InMemoryStore;
 
+    #[tokio::test]
+    async fn list_sites_works() {
+        let store = InMemoryStore::with_test_data().await;
+        let mut sites = store.list_sites().await.unwrap();
+
+        assert_eq!(sites.len(), 2);
+
+        // sort by name
+        sites.sort_by_key(|site| site.domain.clone());
+
+        assert_eq!(sites[0].domain, "example.com");
+        assert_eq!(sites[0].title, "Test site");
+        assert_eq!(sites[1].domain, "other.com");
+        assert_eq!(sites[1].title, "Other site");
+    }
+
     #[tokio::test]
     async fn get_site_by_domain_works() {
         let store = InMemoryStore::with_test_data().await;
diff --git a/src/outbound/repository/site.rs b/src/outbound/repository/site.rs
index ebad384..d265536 100644
--- a/src/outbound/repository/site.rs
+++ b/src/outbound/repository/site.rs
@@ -13,6 +13,7 @@ pub struct SiteMetadata {
 
 #[async_trait::async_trait]
 pub trait SiteRepository: Send + Sync + 'static {
+    async fn list_sites(&self) -> Result<Vec<SiteMetadata>>;
     async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata>;
     async fn create_site(&self, site: SiteMetadata) -> Result<()>;
     async fn delete_site(&self, domain: &str) -> Result<()>;
diff --git a/src/outbound/services/site.rs b/src/outbound/services/site.rs
index 3214833..fa26621 100644
--- a/src/outbound/services/site.rs
+++ b/src/outbound/services/site.rs
@@ -29,6 +29,7 @@ impl SiteServiceProvider {
 #[async_trait::async_trait]
 #[mockall::automock]
 pub trait SiteService: Send + Sync + 'static {
+    async fn list_sites(&self) -> Result<Vec<SiteMetadata>>;
     async fn get_site(&self, domain: &str) -> Result<SiteInfo>;
     async fn create_site(&self, site: SiteMetadata) -> Result<()>;
     async fn delete_site(&self, domain: &str) -> Result<()>;
@@ -58,6 +59,12 @@ impl SiteServiceImpl {
 
 #[async_trait::async_trait]
 impl SiteService for SiteServiceImpl {
+    async fn list_sites(&self) -> Result<Vec<SiteMetadata>> {
+        let sites = self.site_repository.list_sites().await?;
+
+        Ok(sites)
+    }
+
     async fn get_site(&self, domain: &str) -> Result<SiteInfo> {
         let info = self.site_repository.get_site_by_domain(domain).await?;
         let pages = self
@@ -173,6 +180,19 @@ mod tests {
         assert_eq!(info.pages.len(), 2);
     }
 
+    #[tokio::test]
+    async fn gets_site_list() {
+        let service = SiteServiceImpl::new(InMemoryStore::with_test_data().await);
+        let mut sites = service.list_sites().await.unwrap();
+        assert_eq!(sites.len(), 2);
+
+        sites.sort_by_key(|site_info| site_info.domain.clone());
+        assert_eq!(sites[0].domain, "example.com");
+        assert_eq!(sites[0].title, "Test site");
+        assert_eq!(sites[1].domain, "other.com");
+        assert_eq!(sites[1].title, "Other site");
+    }
+
     #[tokio::test]
     async fn gets_nonexistent_site() {
         let service = SiteServiceImpl::new(InMemoryStore::with_test_data().await);