diff --git a/js/schema.graphql b/js/schema.graphql
index b6010ab90..c9e3930dd 100644
--- a/js/schema.graphql
+++ b/js/schema.graphql
@@ -44,7 +44,7 @@ type RefreshedToken {
 }
 
 "Represents an application"
-type Application {
+type Application implements Actor {
   "Internal ID for this application"
   id: ID
 
@@ -336,7 +336,7 @@ type PaginatedPostList {
 }
 
 "A comment"
-type Comment {
+type Comment implements ActionLogObject {
   "Internal ID for this comment"
   id: ID
 
@@ -893,7 +893,7 @@ enum EventCommentModeration {
 }
 
 "Represents a person identity"
-type Person {
+type Person implements ActionLogObject & Actor {
   "Internal ID for this person"
   id: ID
 
@@ -1949,7 +1949,7 @@ type RootQueryType {
 
     "The limit of events per page"
     limit: Int
-  ): [Event]
+  ): PaginatedEventList
 
   "Get an event by uuid"
   event("The event's UUID" uuid: UUID!): Event
@@ -2219,7 +2219,7 @@ input EventOptionsInput {
 }
 
 "A report object"
-type Report {
+type Report implements ActionLogObject {
   "The internal ID of the report"
   id: ID
 
@@ -2285,7 +2285,7 @@ type PaginatedTodoListList {
 }
 
 "An event"
-type Event {
+type Event implements Interactable & ActionLogObject {
   "Internal ID for this event"
   id: ID
 
@@ -2737,7 +2737,7 @@ type Geocoding {
 }
 
 "A report note object"
-type ReportNote {
+type ReportNote implements ActionLogObject {
   "The internal ID of the report note"
   id: ID
 
@@ -3052,7 +3052,7 @@ type Member {
 }
 
 "A local user of Mobilizon"
-type User {
+type User implements ActionLogObject {
   "The user's ID"
   id: ID
 
@@ -3157,7 +3157,7 @@ type User {
 }
 
 "Represents a group of actors"
-type Group {
+type Group implements Interactable & Actor {
   "Internal ID for this group"
   id: ID
 
diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts
index 4b0085837..357e28ce9 100644
--- a/js/src/graphql/event.ts
+++ b/js/src/graphql/event.ts
@@ -196,54 +196,54 @@ export const FETCH_EVENT_BASIC = gql`
 export const FETCH_EVENTS = gql`
   query {
     events {
-      id,
-      uuid,
-      url,
-      local,
-      title,
-      description,
-      beginsOn,
-      endsOn,
-      status,
-      visibility,
-      picture {
+      total
+      elements {
         id
+        uuid
         url
-      },
-      publishAt,
-      # online_address,
-      # phone_address,
-      physicalAddress {
-        id,
-        description,
-        locality
-      },
-      organizerActor {
-        id,
-        avatar {
+        local
+        title
+        description
+        beginsOn
+        endsOn
+        status
+        visibility
+        picture {
           id
           url
-        },
-        preferredUsername,
-        domain,
-        name,
-      },
-#      attributedTo {
-#        avatar {
-#          id
-#          url
-#        },
-#        preferredUsername,
-#        name,
-#      },
-      category,
-      participants {
-        ${participantsQuery}
-      },
-      tags {
-        slug,
-        title
-      },
+        }
+        publishAt
+        # online_address,
+        # phone_address,
+        physicalAddress {
+          id
+          description
+          locality
+        }
+        organizerActor {
+          id
+          avatar {
+            id
+            url
+          }
+          preferredUsername
+          domain
+          name
+        }
+        #      attributedTo {
+        #        avatar {
+        #          id
+        #          url
+        #        },
+        #        preferredUsername,
+        #        name,
+        #      },
+        category
+        tags {
+          slug
+          title
+        }
+      }
     }
   }
 `;
diff --git a/js/src/views/Home.vue b/js/src/views/Home.vue
index 4e4c86508..41c975c25 100644
--- a/js/src/views/Home.vue
+++ b/js/src/views/Home.vue
@@ -220,6 +220,7 @@
 <script lang="ts">
 import { Component, Vue, Watch } from "vue-property-decorator";
 import { ParticipantRole } from "@/types/enums";
+import { Paginate } from "@/types/paginate";
 import { IParticipant, Participant } from "../types/participant.model";
 import { FETCH_EVENTS } from "../graphql/event";
 import EventListCard from "../components/Event/EventListCard.vue";
@@ -295,7 +296,7 @@ import Subtitle from "../components/Utils/Subtitle.vue";
   },
 })
 export default class Home extends Vue {
-  events: IEvent[] = [];
+  events!: Paginate<IEvent>;
 
   locations = [];
 
@@ -437,7 +438,7 @@ export default class Home extends Vue {
    * Return all events from server excluding the ones shown as participating
    */
   get filteredFeaturedEvents(): IEvent[] {
-    return this.events.filter(
+    return this.events.elements.filter(
       ({ id }) =>
         !this.currentUserParticipations
           .filter(
diff --git a/js/src/views/Search.vue b/js/src/views/Search.vue
index 2c2fcf6fc..e02794676 100644
--- a/js/src/views/Search.vue
+++ b/js/src/views/Search.vue
@@ -46,7 +46,7 @@
                 <option
                   v-for="(option, index) in options"
                   :key="index"
-                  :value="option"
+                  :value="index"
                 >
                   {{ option.label }}
                 </option>
@@ -56,20 +56,23 @@
         </form>
       </div>
     </section>
-    <section class="events-featured" v-if="!tag && searchEvents.initial">
+    <section
+      class="events-featured"
+      v-if="!tag && !(search || location.geom || when !== 'any')"
+    >
       <b-loading :active.sync="$apollo.loading"></b-loading>
       <h2 class="title">{{ $t("Featured events") }}</h2>
-      <div v-if="events.length > 0" class="columns is-multiline">
+      <div v-if="events.elements.length > 0" class="columns is-multiline">
         <div
           class="column is-one-third-desktop"
-          v-for="event in events"
+          v-for="event in events.elements"
           :key="event.uuid"
         >
           <EventCard :event="event" />
         </div>
       </div>
       <b-message
-        v-else-if="events.length === 0 && $apollo.loading === false"
+        v-else-if="events.elements.length === 0 && $apollo.loading === false"
         type="is-danger"
         >{{ $t("No events found") }}</b-message
       >
@@ -109,15 +112,24 @@
         <b-message v-else-if="$apollo.loading === false" type="is-danger">{{
           $t("No events found")
         }}</b-message>
+        <b-loading
+          v-else-if="$apollo.loading"
+          :is-full-page="false"
+          v-model="$apollo.loading"
+          :can-cancel="false"
+        />
       </b-tab-item>
-      <b-tab-item v-if="config && config.features.groups">
+      <b-tab-item v-if="!tag">
         <template slot="header">
           <b-icon icon="account-multiple"></b-icon>
           <span>
             {{ $t("Groups") }} <b-tag rounded>{{ searchGroups.total }}</b-tag>
           </span>
         </template>
-        <div v-if="searchGroups.total > 0">
+        <b-message v-if="config && !config.features.groups" type="is-danger">
+          {{ $t("Groups are not enabled on your server.") }}
+        </b-message>
+        <div v-else-if="searchGroups.total > 0">
           <div class="columns is-multiline">
             <div
               class="column is-one-third-desktop"
@@ -143,13 +155,19 @@
         <b-message v-else-if="$apollo.loading === false" type="is-danger">
           {{ $t("No groups found") }}
         </b-message>
+        <b-loading
+          v-else-if="$apollo.loading"
+          :is-full-page="false"
+          v-model="$apollo.loading"
+          :can-cancel="false"
+        />
       </b-tab-item>
     </b-tabs>
   </div>
 </template>
 
 <script lang="ts">
-import { Component, Prop, Vue, Watch } from "vue-property-decorator";
+import { Component, Prop, Vue } from "vue-property-decorator";
 import ngeohash from "ngeohash";
 import {
   endOfToday,
@@ -165,6 +183,7 @@ import {
   eachWeekendOfInterval,
 } from "date-fns";
 import { SearchTabs } from "@/types/enums";
+import { RawLocation } from "vue-router";
 import EventCard from "../components/Event/EventCard.vue";
 import { FETCH_EVENTS } from "../graphql/event";
 import { IEvent } from "../types/event.model";
@@ -183,11 +202,6 @@ interface ISearchTimeOption {
   end?: Date | null;
 }
 
-const tabsName: { events: number; groups: number } = {
-  events: SearchTabs.EVENTS,
-  groups: SearchTabs.GROUPS,
-};
-
 const EVENT_PAGE_LIMIT = 10;
 
 const GROUP_PAGE_LIMIT = 10;
@@ -218,7 +232,7 @@ const GROUP_PAGE_LIMIT = 10;
       },
       debounce: 300,
       skip() {
-        return !this.search && !this.tag && !this.geohash && this.end === null;
+        return !this.tag && !this.geohash && this.end === null;
       },
     },
     searchGroups: {
@@ -250,12 +264,14 @@ const GROUP_PAGE_LIMIT = 10;
 export default class Search extends Vue {
   @Prop({ type: String, required: false }) tag!: string;
 
-  events: IEvent[] = [];
-
-  searchEvents: Paginate<IEvent> & { initial: boolean } = {
+  events: Paginate<IEvent> = {
+    total: 0,
+    elements: [],
+  };
+
+  searchEvents: Paginate<IEvent> = {
     total: 0,
     elements: [],
-    initial: true,
   };
 
   searchGroups: Paginate<IGroup> = { total: 0, elements: [] };
@@ -264,62 +280,51 @@ export default class Search extends Vue {
 
   groupPage = 1;
 
-  search: string = (this.$route.query.term as string) || "";
-
-  activeTab: SearchTabs =
-    tabsName[this.$route.query.searchType as "events" | "groups"] || 0;
-
   location: IAddress = new Address();
 
-  options: ISearchTimeOption[] = [
-    {
+  options: Record<string, ISearchTimeOption> = {
+    today: {
       label: this.$t("Today") as string,
       start: new Date(),
       end: endOfToday(),
     },
-    {
+    tomorrow: {
       label: this.$t("Tomorrow") as string,
       start: startOfDay(addDays(new Date(), 1)),
       end: endOfDay(addDays(new Date(), 1)),
     },
-    {
+    weekend: {
       label: this.$t("This weekend") as string,
       start: this.weekend.start,
       end: this.weekend.end,
     },
-    {
+    week: {
       label: this.$t("This week") as string,
       start: new Date(),
       end: endOfWeek(new Date(), { locale: this.$dateFnsLocale }),
     },
-    {
+    next_week: {
       label: this.$t("Next week") as string,
       start: startOfWeek(addWeeks(new Date(), 1), {
         locale: this.$dateFnsLocale,
       }),
       end: endOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }),
     },
-    {
+    month: {
       label: this.$t("This month") as string,
       start: new Date(),
       end: endOfMonth(new Date()),
     },
-    {
+    next_month: {
       label: this.$t("Next month") as string,
       start: startOfMonth(addMonths(new Date(), 1)),
       end: endOfMonth(addMonths(new Date(), 1)),
     },
-    {
+    any: {
       label: this.$t("Any day") as string,
       start: undefined,
       end: undefined,
     },
-  ];
-
-  when: ISearchTimeOption = {
-    label: this.$t("Any day") as string,
-    start: undefined,
-    end: null,
   };
 
   EVENT_PAGE_LIMIT = EVENT_PAGE_LIMIT;
@@ -335,26 +340,60 @@ export default class Search extends Vue {
 
   radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null];
 
-  radius = 50;
-
   submit(): void {
     this.$apollo.queries.searchEvents.refetch();
   }
 
-  @Watch("search")
-  updateSearchTerm(): void {
-    this.$router.push({
+  get search(): string | undefined {
+    return this.$route.query.term as string;
+  }
+
+  set search(term: string | undefined) {
+    const route: RawLocation = {
       name: RouteName.SEARCH,
-      query: { ...this.$route.query, term: this.search },
+    };
+    if (term !== "") {
+      route.query = { ...this.$route.query, term };
+    }
+    this.$router.replace(route);
+  }
+
+  get activeTab(): SearchTabs {
+    return (
+      parseInt(this.$route.query.searchType as string, 10) || SearchTabs.EVENTS
+    );
+  }
+
+  set activeTab(value: SearchTabs) {
+    this.$router.replace({
+      name: RouteName.SEARCH,
+      query: { ...this.$route.query, searchType: value.toString() },
     });
   }
 
-  @Watch("activeTab")
-  updateActiveTab(): void {
-    const searchType = this.activeTab === tabsName.events ? "events" : "groups";
-    this.$router.push({
+  get radius(): number | null {
+    if (this.$route.query.radius === "any") {
+      return null;
+    }
+    return parseInt(this.$route.query.radius as string, 10) || null;
+  }
+
+  set radius(value: number | null) {
+    const radius = value === null ? "any" : value.toString();
+    this.$router.replace({
       name: RouteName.SEARCH,
-      query: { ...this.$route.query, searchType },
+      query: { ...this.$route.query, radius },
+    });
+  }
+
+  get when(): string {
+    return (this.$route.query.when as string) || "any";
+  }
+
+  set when(value: string) {
+    this.$router.replace({
+      name: RouteName.SEARCH,
+      query: { ...this.$route.query, when: value },
     });
   }
 
@@ -378,11 +417,17 @@ export default class Search extends Vue {
   }
 
   get start(): Date | undefined {
-    return this.when.start;
+    if (this.options[this.when]) {
+      return this.options[this.when].start;
+    }
+    return undefined;
   }
 
   get end(): Date | undefined | null {
-    return this.when.end;
+    if (this.options[this.when]) {
+      return this.options[this.when].end;
+    }
+    return undefined;
   }
 }
 </script>
diff --git a/lib/graphql/resolvers/admin.ex b/lib/graphql/resolvers/admin.ex
index 6aa412882..21698b06c 100644
--- a/lib/graphql/resolvers/admin.ex
+++ b/lib/graphql/resolvers/admin.ex
@@ -190,7 +190,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
       when is_admin(role) do
     last_public_event_published =
       case Events.list_events(1, 1, :inserted_at, :desc) do
-        [event | _] -> event
+        %Page{elements: [event | _]} -> event
         _ -> nil
       end
 
diff --git a/lib/graphql/resolvers/event.ex b/lib/graphql/resolvers/event.ex
index 032a9ee20..ef142157e 100644
--- a/lib/graphql/resolvers/event.ex
+++ b/lib/graphql/resolvers/event.ex
@@ -161,7 +161,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
     events =
       if @number_of_related_events - length(events) > 0 do
         events
-        |> Enum.concat(Events.list_events(1, @number_of_related_events, :begins_on, :asc, true))
+        |> Enum.concat(
+          Events.list_events(1, @number_of_related_events, :begins_on, :asc, true).elements
+        )
         |> uniq_events()
       else
         events
diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex
index e3cd9f31a..0b2ae40f3 100644
--- a/lib/graphql/schema/event.ex
+++ b/lib/graphql/schema/event.ex
@@ -299,7 +299,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
 
   object :event_queries do
     @desc "Get all events"
-    field :events, list_of(:event) do
+    field :events, :paginated_event_list do
       arg(:page, :integer, default_value: 1, description: "The page in the paginated event list")
       arg(:limit, :integer, default_value: 10, description: "The limit of events per page")
       resolve(&Event.list_events/3)
diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index 41ea85b32..89fed4cf4 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -357,7 +357,7 @@ defmodule Mobilizon.Events do
         direction \\ :asc,
         is_future \\ true
       ) do
-    query = from(e in Event, distinct: true, preload: [:organizer_actor, :participants])
+    query = from(e in Event, preload: [:organizer_actor, :participants])
 
     query
     |> sort(sort, direction)
@@ -365,8 +365,7 @@ defmodule Mobilizon.Events do
     |> filter_public_visibility()
     |> filter_draft()
     |> filter_local_or_from_followed_instances_events()
-    |> Page.paginate(page, limit)
-    |> Repo.all()
+    |> Page.build_page(page, limit)
   end
 
   @spec stream_events_for_sitemap :: Enum.t()
diff --git a/lib/mobilizon/storage/page.ex b/lib/mobilizon/storage/page.ex
index af20d4a7b..68fc8aab3 100644
--- a/lib/mobilizon/storage/page.ex
+++ b/lib/mobilizon/storage/page.ex
@@ -19,12 +19,15 @@ defmodule Mobilizon.Storage.Page do
 
   @doc """
   Returns a Page struct for a query.
+
+  `field` is use to define the field that will be used for the count aggregate, which should be the same as the field used for order_by
+    See https://stackoverflow.com/q/12693089/10204399
   """
-  @spec build_page(Ecto.Query.t(), integer | nil, integer | nil) :: t
-  def build_page(query, page, limit) do
+  @spec build_page(Ecto.Query.t(), integer | nil, integer | nil, atom()) :: t
+  def build_page(query, page, limit, field \\ :id) do
     [total, elements] =
       [
-        fn -> Repo.aggregate(query, :count, :id) end,
+        fn -> Repo.aggregate(query, :count, field) end,
         fn -> Repo.all(paginate(query, page, limit)) end
       ]
       |> Enum.map(&Task.async/1)
diff --git a/test/graphql/resolvers/event_test.exs b/test/graphql/resolvers/event_test.exs
index e974ed068..a83559e06 100644
--- a/test/graphql/resolvers/event_test.exs
+++ b/test/graphql/resolvers/event_test.exs
@@ -1144,120 +1144,89 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
              ]
     end
 
-    test "list_events/3 returns events", context do
-      event = insert(:event)
-
-      query = """
-      {
-        events {
-          uuid,
+    @fetch_events_query """
+    query Events($page: Int, $limit: Int) {
+      events(page: $page, limit: $limit) {
+        total
+        elements {
+          uuid
         }
       }
-      """
+    }
+    """
+
+    test "list_events/3 returns events", %{conn: conn} do
+      event = insert(:event)
 
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
+        conn
+        |> AbsintheHelpers.graphql_query(query: @fetch_events_query)
 
-      assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == [event.uuid]
+      assert res["data"]["events"]["elements"] |> Enum.map(& &1["uuid"]) == [
+               event.uuid
+             ]
 
       Enum.each(0..15, fn _ ->
         insert(:event)
       end)
 
-      query = """
-      {
-        events {
-          uuid,
-        }
-      }
-      """
+      res =
+        conn
+        |> AbsintheHelpers.graphql_query(query: @fetch_events_query)
+
+      assert res["data"]["events"]["total"] == 17
+      assert res["data"]["events"]["elements"] |> length == 10
 
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
+        conn
+        |> AbsintheHelpers.graphql_query(query: @fetch_events_query, variables: %{page: 2})
 
-      assert json_response(res, 200)["data"]["events"] |> length == 10
-
-      query = """
-      {
-        events(page: 2) {
-          uuid,
-        }
-      }
-      """
+      assert res["data"]["events"]["total"] == 17
+      assert res["data"]["events"]["elements"] |> length == 7
 
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
+        conn
+        |> AbsintheHelpers.graphql_query(
+          query: @fetch_events_query,
+          variables: %{page: 2, limit: 15}
+        )
 
-      assert json_response(res, 200)["data"]["events"] |> length == 7
-
-      query = """
-      {
-        events(page: 2, limit: 15) {
-          uuid,
-        }
-      }
-      """
+      assert res["data"]["events"]["total"] == 17
+      assert res["data"]["events"]["elements"] |> length == 2
 
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
+        conn
+        |> AbsintheHelpers.graphql_query(
+          query: @fetch_events_query,
+          variables: %{page: 3, limit: 15}
+        )
 
-      assert json_response(res, 200)["data"]["events"] |> length == 2
-
-      query = """
-      {
-        events(page: 3, limit: 15) {
-          uuid,
-        }
-      }
-      """
-
-      res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
-
-      assert json_response(res, 200)["data"]["events"] |> length == 0
+      assert res["data"]["events"]["total"] == 17
+      assert res["data"]["events"]["elements"] |> length == 0
     end
 
-    test "list_events/3 doesn't list private events", context do
+    test "list_events/3 doesn't list private events", %{conn: conn} do
       insert(:event, visibility: :private)
       insert(:event, visibility: :unlisted)
       insert(:event, visibility: :restricted)
 
-      query = """
-      {
-        events {
-          uuid,
-        }
-      }
-      """
-
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
+        conn
+        |> AbsintheHelpers.graphql_query(query: @fetch_events_query)
 
-      assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == []
+      assert res["data"]["events"]["total"] == 0
+      assert res["data"]["events"]["elements"] |> Enum.map(& &1["uuid"]) == []
     end
 
-    test "list_events/3 doesn't list draft events", context do
+    test "list_events/3 doesn't list draft events", %{conn: conn} do
       insert(:event, visibility: :public, draft: true)
 
-      query = """
-      {
-        events {
-          uuid,
-        }
-      }
-      """
-
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
+        conn
+        |> AbsintheHelpers.graphql_query(query: @fetch_events_query)
 
-      assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == []
+      assert res["data"]["events"]["total"] == 0
+      assert res["data"]["events"]["elements"] |> Enum.map(& &1["uuid"]) == []
     end
 
     test "find_event/3 returns an unlisted event", context do
diff --git a/test/mobilizon/events/events_test.exs b/test/mobilizon/events/events_test.exs
index a033b8a2c..fbda5b00a 100644
--- a/test/mobilizon/events/events_test.exs
+++ b/test/mobilizon/events/events_test.exs
@@ -29,12 +29,12 @@ defmodule Mobilizon.EventsTest do
     end
 
     test "list_events/0 returns all events", %{event: event} do
-      assert event.title == hd(Events.list_events()).title
+      assert event.title == hd(Events.list_events().elements).title
     end
 
     test "list_events/5 returns events from other instances if we follow them",
          %{event: _event} do
-      events = Events.list_events()
+      events = Events.list_events().elements
       assert length(events) == 1
 
       %Actor{id: remote_instance_actor_id} = remote_instance_actor = insert(:instance_actor)
@@ -46,7 +46,7 @@ defmodule Mobilizon.EventsTest do
 
       insert(:follower, target_actor: remote_instance_actor, actor: own_instance_actor)
 
-      events = Events.list_events()
+      events = Events.list_events().elements
       assert length(events) == 2
       assert events |> Enum.any?(fn event -> event.title == "My Remote event" end)
     end
@@ -58,7 +58,7 @@ defmodule Mobilizon.EventsTest do
       %Event{url: remote_event_url} = insert(:event, local: false, title: "My Remote event")
       Mobilizon.Share.create(remote_event_url, remote_instance_actor_id, remote_actor_id)
 
-      events = Events.list_events()
+      events = Events.list_events().elements
       assert length(events) == 1
       assert events |> Enum.all?(fn event -> event.title != "My Remote event" end)
     end