diff --git a/swh/graphql/resolvers/base_connection.py b/swh/graphql/resolvers/base_connection.py
--- a/swh/graphql/resolvers/base_connection.py
+++ b/swh/graphql/resolvers/base_connection.py
@@ -5,7 +5,7 @@
 
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
-from typing import Optional, Type
+from typing import Any, Optional, Type
 
 from swh.graphql.utils import utils
 
@@ -18,6 +18,12 @@
     endCursor: str
 
 
+@dataclass
+class ConnectionEdge:
+    node: Any
+    cursor: str
+
+
 class BaseConnection(ABC):
     """
     Base resolver for all the connections
@@ -94,9 +100,13 @@
         return None
 
     def _get_edges(self):
-        # FIXME, make cursor work per item
-        # Cursor can't be None here
-        return [{"cursor": "dummy", "node": node} for node in self.nodes]
+        """
+        Return the list of connection edges, each with a cursor
+        """
+        return [
+            ConnectionEdge(node=node, cursor=self._get_index_cursor(index, node))
+            for (index, node) in enumerate(self.nodes)
+        ]
 
     def _get_after_arg(self):
         """
@@ -110,3 +120,13 @@
         page_size is set to 50 by default
         """
         return self.kwargs.get("first", self._page_size)
+
+    def _get_index_cursor(self, index: int, node: Any):
+        """
+        Get the cursor to the given item index
+        """
+        # default implementation which works with swh-storage pagaination
+        # override this function to support other types (eg: SnapshotBranchConnection)
+        offset_index = self._get_after_arg() or 0
+        index_cursor = int(offset_index) + index
+        return utils.get_encoded_cursor(str(index_cursor))
diff --git a/swh/graphql/resolvers/snapshot_branch.py b/swh/graphql/resolvers/snapshot_branch.py
--- a/swh/graphql/resolvers/snapshot_branch.py
+++ b/swh/graphql/resolvers/snapshot_branch.py
@@ -77,3 +77,7 @@
         # the base class, hack to make that work
         after = utils.get_decoded_cursor(self.kwargs.get("after", ""))
         return bytes.fromhex(after)
+
+    def _get_index_cursor(self, index: int, node: SnapshotBranchNode):
+        # Snapshot branch is using a different cursor, hence the override
+        return utils.get_encoded_cursor(node.name.hex())
diff --git a/swh/graphql/tests/functional/test_pagination.py b/swh/graphql/tests/functional/test_pagination.py
new file mode 100644
--- /dev/null
+++ b/swh/graphql/tests/functional/test_pagination.py
@@ -0,0 +1,98 @@
+# Copyright (C) 2022 The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+from ..data import get_origins
+from .utils import get_query_response
+
+
+# Using Origin object to run functional tests for pagination
+def test_pagination(client):
+    # requesting the max number of nodes available
+    # endCursor must be None
+    query_str = f"""
+    {{
+      origins(first: {len(get_origins())}) {{
+        nodes {{
+          id
+        }}
+        pageInfo {{
+          hasNextPage
+          endCursor
+        }}
+      }}
+    }}
+    """
+
+    data, _ = get_query_response(client, query_str)
+    assert len(data["origins"]["nodes"]) == len(get_origins())
+    assert data["origins"]["pageInfo"] == {"hasNextPage": False, "endCursor": None}
+
+
+def get_first_node(client):
+    query_str = """
+    {
+      origins(first: 1) {
+        nodes {
+          id
+        }
+        pageInfo {
+          hasNextPage
+          endCursor
+        }
+      }
+    }
+    """
+    data, _ = get_query_response(client, query_str)
+    return data["origins"]
+
+
+def test_first_arg(client):
+    origins = get_first_node(client)
+    assert len(origins["nodes"]) == 1
+    assert origins["pageInfo"]["hasNextPage"] is True
+
+
+def test_after_arg(client):
+    origins = get_first_node(client)
+    end_cursor = origins["pageInfo"]["endCursor"]
+    query_str = f"""
+    {{
+      origins(first: 1, after: "{end_cursor}") {{
+        nodes {{
+          id
+        }}
+        pageInfo {{
+          hasNextPage
+          endCursor
+        }}
+      }}
+    }}
+    """
+    data, _ = get_query_response(client, query_str)
+    assert len(data["origins"]["nodes"]) == 1
+    assert data["origins"]["pageInfo"] == {"hasNextPage": False, "endCursor": None}
+
+
+def test_edge_cursor(client):
+    query_str = """
+    {
+      origins(first: 2) {
+        edges {
+          cursor
+          node {
+            id
+          }
+        }
+        nodes {
+          id
+        }
+      }
+    }
+    """
+    data, _ = get_query_response(client, query_str)
+    origins = data["origins"]
+    # nodes in list node fields in edges must be the same
+    assert [edge["node"] for edge in origins["edges"]] == origins["nodes"]
+    # FIXME, test item (index based) cursor (it will not work for in-memory db now)