diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index aee9990bc0b1f30d24128f4ef5da3deb7bc1e884..6ec77186298e137feefa18088f05bc762686596e 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -5,6 +5,8 @@ import { joinPaths } from './lib/utils/url_utility';
 import flash from '~/flash';
 import { __ } from '~/locale';
 
+const DEFAULT_PER_PAGE = 20;
+
 const Api = {
   groupsPath: '/api/:version/groups.json',
   groupPath: '/api/:version/groups/:id',
@@ -66,7 +68,7 @@ const Api = {
         params: Object.assign(
           {
             search: query,
-            per_page: 20,
+            per_page: DEFAULT_PER_PAGE,
           },
           options,
         ),
@@ -90,7 +92,7 @@ const Api = {
       .get(url, {
         params: {
           search: query,
-          per_page: 20,
+          per_page: DEFAULT_PER_PAGE,
         },
       })
       .then(({ data }) => callback(data));
@@ -101,7 +103,7 @@ const Api = {
     const url = Api.buildUrl(Api.projectsPath);
     const defaults = {
       search: query,
-      per_page: 20,
+      per_page: DEFAULT_PER_PAGE,
       simple: true,
     };
 
@@ -126,7 +128,7 @@ const Api = {
       .get(url, {
         params: {
           search: query,
-          per_page: 20,
+          per_page: DEFAULT_PER_PAGE,
           ...options,
         },
       })
@@ -235,7 +237,7 @@ const Api = {
     const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId);
     const defaults = {
       search: query,
-      per_page: 20,
+      per_page: DEFAULT_PER_PAGE,
     };
     return axios
       .get(url, {
@@ -325,7 +327,7 @@ const Api = {
       params: Object.assign(
         {
           search: query,
-          per_page: 20,
+          per_page: DEFAULT_PER_PAGE,
         },
         options,
       ),
@@ -355,7 +357,7 @@ const Api = {
     const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId);
     const defaults = {
       search: query,
-      per_page: 20,
+      per_page: DEFAULT_PER_PAGE,
     };
     return axios
       .get(url, {
@@ -371,7 +373,7 @@ const Api = {
     return axios.get(url, {
       params: {
         search: query,
-        per_page: 20,
+        per_page: DEFAULT_PER_PAGE,
         ...options,
       },
     });
@@ -403,10 +405,15 @@ const Api = {
     return axios.post(url);
   },
 
-  releases(id) {
+  releases(id, options = {}) {
     const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id));
 
-    return axios.get(url);
+    return axios.get(url, {
+      params: {
+        per_page: DEFAULT_PER_PAGE,
+        ...options,
+      },
+    });
   },
 
   release(projectPath, tagName) {
diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue
index 5a06c4fec58d6841201c4601f79ced3d2371f67a..a414b3ccd4ec09e2c7761a100afd7637ff5acb41 100644
--- a/app/assets/javascripts/releases/list/components/app.vue
+++ b/app/assets/javascripts/releases/list/components/app.vue
@@ -1,6 +1,12 @@
 <script>
 import { mapState, mapActions } from 'vuex';
 import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui';
+import {
+  getParameterByName,
+  historyPushState,
+  buildUrlWithCurrentLocation,
+} from '~/lib/utils/common_utils';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
 import ReleaseBlock from './release_block.vue';
 
 export default {
@@ -9,6 +15,7 @@ export default {
     GlSkeletonLoading,
     GlEmptyState,
     ReleaseBlock,
+    TablePagination,
   },
   props: {
     projectId: {
@@ -25,7 +32,7 @@ export default {
     },
   },
   computed: {
-    ...mapState(['isLoading', 'releases', 'hasError']),
+    ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']),
     shouldRenderEmptyState() {
       return !this.releases.length && !this.hasError && !this.isLoading;
     },
@@ -34,10 +41,17 @@ export default {
     },
   },
   created() {
-    this.fetchReleases(this.projectId);
+    this.fetchReleases({
+      page: getParameterByName('page'),
+      projectId: this.projectId,
+    });
   },
   methods: {
     ...mapActions(['fetchReleases']),
+    onChangePage(page) {
+      historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
+      this.fetchReleases({ page, projectId: this.projectId });
+    },
   },
 };
 </script>
@@ -67,6 +81,8 @@ export default {
         :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
       />
     </div>
+
+    <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" />
   </div>
 </template>
 <style>
diff --git a/app/assets/javascripts/releases/list/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js
index e0a922d5ef6b7f35b6faef56384152d63bd38b90..b15fb69226f3b57d390dbcf517a6c2da56291a51 100644
--- a/app/assets/javascripts/releases/list/store/actions.js
+++ b/app/assets/javascripts/releases/list/store/actions.js
@@ -2,6 +2,7 @@ import * as types from './mutation_types';
 import createFlash from '~/flash';
 import { __ } from '~/locale';
 import api from '~/api';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
 
 /**
  * Commits a mutation to update the state while the main endpoint is being requested.
@@ -16,17 +17,19 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
  *
  * @param {String} projectId
  */
-export const fetchReleases = ({ dispatch }, projectId) => {
+export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => {
   dispatch('requestReleases');
 
   api
-    .releases(projectId)
-    .then(({ data }) => dispatch('receiveReleasesSuccess', data))
+    .releases(projectId, { page })
+    .then(response => dispatch('receiveReleasesSuccess', response))
     .catch(() => dispatch('receiveReleasesError'));
 };
 
-export const receiveReleasesSuccess = ({ commit }, data) =>
-  commit(types.RECEIVE_RELEASES_SUCCESS, data);
+export const receiveReleasesSuccess = ({ commit }, { data, headers }) => {
+  const pageInfo = parseIntPagination(normalizeHeaders(headers));
+  commit(types.RECEIVE_RELEASES_SUCCESS, { data, pageInfo });
+};
 
 export const receiveReleasesError = ({ commit }) => {
   commit(types.RECEIVE_RELEASES_ERROR);
diff --git a/app/assets/javascripts/releases/list/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js
index b97dc6cb0abec6a98577b55b593947f9ea95a5d4..99fc096264aeeb9ddbeb18831691c942b160206a 100644
--- a/app/assets/javascripts/releases/list/store/mutations.js
+++ b/app/assets/javascripts/releases/list/store/mutations.js
@@ -13,13 +13,15 @@ export default {
    * Sets isLoading to false.
    * Sets hasError to false.
    * Sets the received data
+   * Sets the received pagination information
    * @param {Object} state
-   * @param {Object} data
+   * @param {Object} resp
    */
-  [types.RECEIVE_RELEASES_SUCCESS](state, data) {
+  [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
     state.hasError = false;
     state.isLoading = false;
     state.releases = data;
+    state.pageInfo = pageInfo;
   },
 
   /**
diff --git a/app/assets/javascripts/releases/list/store/state.js b/app/assets/javascripts/releases/list/store/state.js
index bf25e651c99bb48c757cffa9863de7483d7e16ec..c251f56c9c551b9d720032d7fdc5f76947b15281 100644
--- a/app/assets/javascripts/releases/list/store/state.js
+++ b/app/assets/javascripts/releases/list/store/state.js
@@ -2,4 +2,5 @@ export default () => ({
   isLoading: false,
   hasError: false,
   releases: [],
+  pageInfo: {},
 });
diff --git a/changelogs/unreleased/feat-ui-releases-pagination.yml b/changelogs/unreleased/feat-ui-releases-pagination.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8f6efe8ca0192a1efecb19b19fb1e3dff87f53e8
--- /dev/null
+++ b/changelogs/unreleased/feat-ui-releases-pagination.yml
@@ -0,0 +1,5 @@
+---
+title: Implement pagination for project releases page
+merge_request: 19912
+author: Fabio Huser
+type: added
diff --git a/spec/javascripts/releases/list/components/app_spec.js b/spec/javascripts/releases/list/components/app_spec.js
index 471c442e497bc46a17ab8a4f1296f5fbfb39d651..994488581d7c9a33998de371f5ffd9e5679bc94f 100644
--- a/spec/javascripts/releases/list/components/app_spec.js
+++ b/spec/javascripts/releases/list/components/app_spec.js
@@ -1,15 +1,22 @@
 import Vue from 'vue';
+import _ from 'underscore';
 import app from '~/releases/list/components/app.vue';
 import createStore from '~/releases/list/store';
 import api from '~/api';
 import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
 import { resetStore } from '../store/helpers';
-import { releases } from '../../mock_data';
+import {
+  pageInfoHeadersWithoutPagination,
+  pageInfoHeadersWithPagination,
+  release,
+  releases,
+} from '../../mock_data';
 
 describe('Releases App ', () => {
   const Component = Vue.extend(app);
   let store;
   let vm;
+  let releasesPagination;
 
   const props = {
     projectId: 'gitlab-ce',
@@ -19,6 +26,7 @@ describe('Releases App ', () => {
 
   beforeEach(() => {
     store = createStore();
+    releasesPagination = _.range(21).map(index => ({ ...release, tag_name: `${index}.00` }));
   });
 
   afterEach(() => {
@@ -28,7 +36,7 @@ describe('Releases App ', () => {
 
   describe('while loading', () => {
     beforeEach(() => {
-      spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] }));
+      spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} }));
       vm = mountComponentWithStore(Component, { props, store });
     });
 
@@ -36,6 +44,7 @@ describe('Releases App ', () => {
       expect(vm.$el.querySelector('.js-loading')).not.toBeNull();
       expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
       expect(vm.$el.querySelector('.js-success-state')).toBeNull();
+      expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
 
       setTimeout(() => {
         done();
@@ -45,7 +54,9 @@ describe('Releases App ', () => {
 
   describe('with successful request', () => {
     beforeEach(() => {
-      spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases }));
+      spyOn(api, 'releases').and.returnValue(
+        Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }),
+      );
       vm = mountComponentWithStore(Component, { props, store });
     });
 
@@ -54,6 +65,27 @@ describe('Releases App ', () => {
         expect(vm.$el.querySelector('.js-loading')).toBeNull();
         expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
         expect(vm.$el.querySelector('.js-success-state')).not.toBeNull();
+        expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
+
+        done();
+      }, 0);
+    });
+  });
+
+  describe('with successful request and pagination', () => {
+    beforeEach(() => {
+      spyOn(api, 'releases').and.returnValue(
+        Promise.resolve({ data: releasesPagination, headers: pageInfoHeadersWithPagination }),
+      );
+      vm = mountComponentWithStore(Component, { props, store });
+    });
+
+    it('renders success state', done => {
+      setTimeout(() => {
+        expect(vm.$el.querySelector('.js-loading')).toBeNull();
+        expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
+        expect(vm.$el.querySelector('.js-success-state')).not.toBeNull();
+        expect(vm.$el.querySelector('.gl-pagination')).not.toBeNull();
 
         done();
       }, 0);
@@ -62,7 +94,7 @@ describe('Releases App ', () => {
 
   describe('with empty request', () => {
     beforeEach(() => {
-      spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] }));
+      spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} }));
       vm = mountComponentWithStore(Component, { props, store });
     });
 
@@ -71,6 +103,7 @@ describe('Releases App ', () => {
         expect(vm.$el.querySelector('.js-loading')).toBeNull();
         expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull();
         expect(vm.$el.querySelector('.js-success-state')).toBeNull();
+        expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
 
         done();
       }, 0);
diff --git a/spec/javascripts/releases/list/store/actions_spec.js b/spec/javascripts/releases/list/store/actions_spec.js
index 8e78a631a5f9e212e89ea3098ce60a9b8f7e20f1..c4b49c39e2810cd1aa5dc43b313776f3d0a43be7 100644
--- a/spec/javascripts/releases/list/store/actions_spec.js
+++ b/spec/javascripts/releases/list/store/actions_spec.js
@@ -7,14 +7,17 @@ import {
 import state from '~/releases/list/store/state';
 import * as types from '~/releases/list/store/mutation_types';
 import api from '~/api';
+import { parseIntPagination } from '~/lib/utils/common_utils';
 import testAction from 'spec/helpers/vuex_action_helper';
-import { releases } from '../../mock_data';
+import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data';
 
 describe('Releases State actions', () => {
   let mockedState;
+  let pageInfo;
 
   beforeEach(() => {
     mockedState = state();
+    pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
   });
 
   describe('requestReleases', () => {
@@ -25,12 +28,16 @@ describe('Releases State actions', () => {
 
   describe('fetchReleases', () => {
     describe('success', () => {
-      it('dispatches requestReleases and receiveReleasesSuccess ', done => {
-        spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases }));
+      it('dispatches requestReleases and receiveReleasesSuccess', done => {
+        spyOn(api, 'releases').and.callFake((id, options) => {
+          expect(id).toEqual(1);
+          expect(options.page).toEqual('1');
+          return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
+        });
 
         testAction(
           fetchReleases,
-          releases,
+          { projectId: 1 },
           mockedState,
           [],
           [
@@ -38,7 +45,31 @@ describe('Releases State actions', () => {
               type: 'requestReleases',
             },
             {
-              payload: releases,
+              payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
+              type: 'receiveReleasesSuccess',
+            },
+          ],
+          done,
+        );
+      });
+
+      it('dispatches requestReleases and receiveReleasesSuccess on page two', done => {
+        spyOn(api, 'releases').and.callFake((_, options) => {
+          expect(options.page).toEqual('2');
+          return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
+        });
+
+        testAction(
+          fetchReleases,
+          { page: '2', projectId: 1 },
+          mockedState,
+          [],
+          [
+            {
+              type: 'requestReleases',
+            },
+            {
+              payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
               type: 'receiveReleasesSuccess',
             },
           ],
@@ -48,12 +79,12 @@ describe('Releases State actions', () => {
     });
 
     describe('error', () => {
-      it('dispatches requestReleases and receiveReleasesError ', done => {
+      it('dispatches requestReleases and receiveReleasesError', done => {
         spyOn(api, 'releases').and.returnValue(Promise.reject());
 
         testAction(
           fetchReleases,
-          null,
+          { projectId: null },
           mockedState,
           [],
           [
@@ -74,9 +105,9 @@ describe('Releases State actions', () => {
     it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => {
       testAction(
         receiveReleasesSuccess,
-        releases,
+        { data: releases, headers: pageInfoHeadersWithoutPagination },
         mockedState,
-        [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: releases }],
+        [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }],
         [],
         done,
       );
diff --git a/spec/javascripts/releases/list/store/mutations_spec.js b/spec/javascripts/releases/list/store/mutations_spec.js
index d2577891495a98562388685dcc2dd2742c8555fe..d756c69d53b7c6a0c3296e4cd8644ea97e150e78 100644
--- a/spec/javascripts/releases/list/store/mutations_spec.js
+++ b/spec/javascripts/releases/list/store/mutations_spec.js
@@ -1,13 +1,16 @@
 import state from '~/releases/list/store/state';
 import mutations from '~/releases/list/store/mutations';
 import * as types from '~/releases/list/store/mutation_types';
-import { releases } from '../../mock_data';
+import { parseIntPagination } from '~/lib/utils/common_utils';
+import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data';
 
 describe('Releases Store Mutations', () => {
   let stateCopy;
+  let pageInfo;
 
   beforeEach(() => {
     stateCopy = state();
+    pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
   });
 
   describe('REQUEST_RELEASES', () => {
@@ -20,7 +23,7 @@ describe('Releases Store Mutations', () => {
 
   describe('RECEIVE_RELEASES_SUCCESS', () => {
     beforeEach(() => {
-      mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, releases);
+      mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases });
     });
 
     it('sets is loading to false', () => {
@@ -34,6 +37,10 @@ describe('Releases Store Mutations', () => {
     it('sets data', () => {
       expect(stateCopy.releases).toEqual(releases);
     });
+
+    it('sets pageInfo', () => {
+      expect(stateCopy.pageInfo).toEqual(pageInfo);
+    });
   });
 
   describe('RECEIVE_RELEASES_ERROR', () => {
@@ -42,6 +49,7 @@ describe('Releases Store Mutations', () => {
 
       expect(stateCopy.isLoading).toEqual(false);
       expect(stateCopy.releases).toEqual([]);
+      expect(stateCopy.pageInfo).toEqual({});
     });
   });
 });
diff --git a/spec/javascripts/releases/mock_data.js b/spec/javascripts/releases/mock_data.js
index 7197eb7bca8bcb64c655d3d18d0bf427664808ef..72875dff1720833b74a0987d05f85d3eae3a74ed 100644
--- a/spec/javascripts/releases/mock_data.js
+++ b/spec/javascripts/releases/mock_data.js
@@ -1,3 +1,21 @@
+export const pageInfoHeadersWithoutPagination = {
+  'X-NEXT-PAGE': '',
+  'X-PAGE': '1',
+  'X-PER-PAGE': '20',
+  'X-PREV-PAGE': '',
+  'X-TOTAL': '19',
+  'X-TOTAL-PAGES': '1',
+};
+
+export const pageInfoHeadersWithPagination = {
+  'X-NEXT-PAGE': '2',
+  'X-PAGE': '1',
+  'X-PER-PAGE': '20',
+  'X-PREV-PAGE': '',
+  'X-TOTAL': '21',
+  'X-TOTAL-PAGES': '2',
+};
+
 export const release = {
   name: 'Bionic Beaver',
   tag_name: '18.04',