diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a37f73f66c22604ffd29c7f25b08b7046bb22c4..47b7b0564e9b9386028ad4d9d526c6f384a12c31 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
 ### Fixed
 - Fix weekly report time range plausible/analytics#951
 - Make sure embedded dashboards can run when user has blocked third-party cookies plausible/analytics#971
+- Sites listing page will paginate if the user has a lot of sites plausible/analytics#994
 
 ### Removed
 - Removes AppSignal monitoring package
diff --git a/assets/css/app.css b/assets/css/app.css
index 11a7168f373eca5de1cc6a972e546686f6b59966..59cabe8827a8572b5ab698bd69a9eb8bc883237a 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -304,3 +304,7 @@ blockquote {
 iframe[hidden] {
   display: none;
 }
+
+.pagination-link[disabled] {
+  @apply cursor-default bg-gray-100 pointer-events-none;
+}
diff --git a/lib/plausible/repo.ex b/lib/plausible/repo.ex
index 2f9147e662e0ef0ad2bd3bc510a8cae0f650c1fa..6534885210b9f70755a5e847fb577411787bd676 100644
--- a/lib/plausible/repo.ex
+++ b/lib/plausible/repo.ex
@@ -3,6 +3,8 @@ defmodule Plausible.Repo do
     otp_app: :plausible,
     adapter: Ecto.Adapters.Postgres
 
+  use Phoenix.Pagination, per_page: 18
+
   defmacro __using__(_) do
     quote do
       alias Plausible.Repo
diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex
index 13252eebb288e45a17ac47ba6d134c860973b171..0599ed92925f65f4a5f0d0243b44f191fd74642e 100644
--- a/lib/plausible_web/controllers/site_controller.ex
+++ b/lib/plausible_web/controllers/site_controller.ex
@@ -5,20 +5,22 @@ defmodule PlausibleWeb.SiteController do
 
   plug PlausibleWeb.RequireAccountPlug
 
-  def index(conn, _params) do
+  def index(conn, params) do
     user = conn.assigns[:current_user]
 
-    sites =
-      Repo.all(
-        from s in Plausible.Site,
+    {sites, pagination} =
+      Repo.paginate(
+        from(s in Plausible.Site,
           join: sm in Plausible.Site.Membership,
           on: sm.site_id == s.id,
           where: sm.user_id == ^user.id,
           order_by: s.domain
+        ),
+        params
       )
 
     visitors = Plausible.Stats.Clickhouse.last_24h_visitors(sites)
-    render(conn, "index.html", sites: sites, visitors: visitors)
+    render(conn, "index.html", sites: sites, visitors: visitors, pagination: pagination)
   end
 
   def new(conn, _params) do
diff --git a/lib/plausible_web/templates/site/index.html.eex b/lib/plausible_web/templates/site/index.html.eex
index eed01ccb29e9428295b7281e8861770eddad0428..192ed458809f84c1769d88ce5e9cfd6cbf8ce12b 100644
--- a/lib/plausible_web/templates/site/index.html.eex
+++ b/lib/plausible_web/templates/site/index.html.eex
@@ -37,4 +37,25 @@
       </div>
     <% end %>
   </ul>
+
+  <%= if @pagination.total_pages > 1 do %>
+    <%= pagination @conn, @pagination, [current_class: "is-current"], fn p -> %>
+      <nav class="px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6" aria-label="Pagination">
+        <div class="hidden sm:block">
+          <p class="text-sm text-gray-700">
+          Showing page
+          <span class="font-medium"><%= @pagination.page %></span>
+          of
+          <span class="font-medium"><%= @pagination.total_pages %></span>
+          total
+          </p>
+        </div>
+        <div class="flex-1 flex justify-between sm:justify-end">
+          <%= pagination_link(p, :previous, label: "← Previous", class: "pagination-link relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50", force_show: true) %>
+          <%= pagination_link(p, :next, label: "Next →", class: "pagination-link ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50", force_show: true) %>
+        </div>
+      </nav>
+    <% end %>
+  <% end %>
+
 </div>
diff --git a/lib/plausible_web/views/site_view.ex b/lib/plausible_web/views/site_view.ex
index 5fd8dfcaeb94512e7b6e0046323e773a200a48df..40c553c4629f781de82653e7f21162fd18a26f5b 100644
--- a/lib/plausible_web/views/site_view.ex
+++ b/lib/plausible_web/views/site_view.ex
@@ -1,5 +1,6 @@
 defmodule PlausibleWeb.SiteView do
   use PlausibleWeb, :view
+  import Phoenix.Pagination.HTML
 
   def admin_email do
     Application.get_env(:plausible, :admin_email)
diff --git a/mix.exs b/mix.exs
index 7eb6af5be3586e89e10dedfa1c69edd50a08fb10..f6e3b9aef38fc9bad545bb80601dfcbfe7bbd2fb 100644
--- a/mix.exs
+++ b/mix.exs
@@ -94,7 +94,8 @@ defmodule Plausible.MixProject do
       {:credo, "~> 1.5", only: [:dev, :test], runtime: false},
       {:kaffy, "~> 0.9.0"},
       {:envy, "~> 1.1.1"},
-      {:ink, "~> 1.0"}
+      {:ink, "~> 1.0"},
+      {:phoenix_pagination, "~> 0.7.0"}
     ]
   end
 
diff --git a/mix.lock b/mix.lock
index 273a6aab17ae9bb93bc450a5ecf9579214d3d5a5..2abdce41cb6d91cea0c6e5d3941d0c31f558ba8f 100644
--- a/mix.lock
+++ b/mix.lock
@@ -65,6 +65,7 @@
   "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"},
   "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"},
   "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"},
+  "phoenix_pagination": {:hex, :phoenix_pagination, "0.7.0", "e8503270da3c41f4ac4fea5ae90503f51287e9cd72b3a6abb0c547fe84d9639b", [:mix], [{:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.12", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.11.1", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "27055338e9824bb302bb0d72e14972a9a2fb916bf435545f04f361671d6d827f"},
   "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
   "php_serializer": {:hex, :php_serializer, "0.9.2", "59c5fd6bd3096671fd89358fb8229341ac7423b50ad8d45a15213b02ea2edab2", [:mix], [], "hexpm", "34eb835a460944f7fc216773b363c02e7dcf8ac0390c9e9ccdbd92b31a7ca59a"},
   "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"},
diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs
index b79324302c4ccbcbf262c40303e5029eb9100c5e..52b139c0a75644f917011f44bd356f222b70b763 100644
--- a/test/plausible_web/controllers/site_controller_test.exs
+++ b/test/plausible_web/controllers/site_controller_test.exs
@@ -43,6 +43,27 @@ defmodule PlausibleWeb.SiteControllerTest do
       assert html_response(conn, 200) =~ "test-site.com"
       assert html_response(conn, 200) =~ "<b>3</b> visitors in last 24h"
     end
+
+    test "paginates sites", %{conn: conn, user: user} do
+      insert(:site, members: [user], domain: "test-site1.com")
+      insert(:site, members: [user], domain: "test-site2.com")
+      insert(:site, members: [user], domain: "test-site3.com")
+      insert(:site, members: [user], domain: "test-site4.com")
+
+      conn = get(conn, "/sites?per_page=2")
+
+      assert html_response(conn, 200) =~ "test-site1.com"
+      assert html_response(conn, 200) =~ "test-site2.com"
+      refute html_response(conn, 200) =~ "test-site3.com"
+      refute html_response(conn, 200) =~ "test-site4.com"
+
+      conn = get(conn, "/sites?per_page=2&page=2")
+
+      refute html_response(conn, 200) =~ "test-site1.com"
+      refute html_response(conn, 200) =~ "test-site2.com"
+      assert html_response(conn, 200) =~ "test-site3.com"
+      assert html_response(conn, 200) =~ "test-site4.com"
+    end
   end
 
   describe "POST /sites" do