diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..7e973a34e5c533869611476d7b5b5cbc690202af
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -0,0 +1,9 @@
+export const GRAPHQL_PAGE_SIZE = 30;
+
+export const initialPaginationState = {
+  currentPage: 1,
+  prevPageCursor: '',
+  nextPageCursor: '',
+  first: GRAPHQL_PAGE_SIZE,
+  last: null,
+};
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index c2104754bad344f490d807b024be94aa24e1bddd..68c6584cda623df6fd470547347396c956ab7887 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -1,6 +1,13 @@
-query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
+query getJobs(
+  $fullPath: ID!
+  $first: Int
+  $last: Int
+  $after: String
+  $before: String
+  $statuses: [CiJobStatus!]
+) {
   project(fullPath: $fullPath) {
-    jobs(first: 20, statuses: $statuses) {
+    jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) {
       pageInfo {
         endCursor
         hasNextPage
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index 4bbb292ad94d17b3c157f5967b29574f2f8aa1a5..2061b1f1eb21622e1527cf22281de062c1af125d 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -1,6 +1,7 @@
 <script>
-import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
 import { __ } from '~/locale';
+import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants';
 import GetJobs from './graphql/queries/get_jobs.query.graphql';
 import JobsTable from './jobs_table.vue';
 import JobsTableEmptyState from './jobs_table_empty_state.vue';
@@ -12,6 +13,7 @@ export default {
   },
   components: {
     GlAlert,
+    GlPagination,
     GlSkeletonLoader,
     JobsTable,
     JobsTableEmptyState,
@@ -28,10 +30,18 @@ export default {
       variables() {
         return {
           fullPath: this.fullPath,
+          first: this.pagination.first,
+          last: this.pagination.last,
+          after: this.pagination.nextPageCursor,
+          before: this.pagination.prevPageCursor,
         };
       },
-      update({ project }) {
-        return project?.jobs?.nodes || [];
+      update(data) {
+        const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {};
+        return {
+          list,
+          pageInfo,
+        };
       },
       error() {
         this.hasError = true;
@@ -40,10 +50,11 @@ export default {
   },
   data() {
     return {
-      jobs: null,
+      jobs: {},
       hasError: false,
       isAlertDismissed: false,
       scope: null,
+      pagination: initialPaginationState,
     };
   },
   computed: {
@@ -51,7 +62,16 @@ export default {
       return this.hasError && !this.isAlertDismissed;
     },
     showEmptyState() {
-      return this.jobs.length === 0 && !this.scope;
+      return this.jobs.list.length === 0 && !this.scope;
+    },
+    prevPage() {
+      return Math.max(this.pagination.currentPage - 1, 0);
+    },
+    nextPage() {
+      return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null;
+    },
+    showPaginationControls() {
+      return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading;
     },
   },
   methods: {
@@ -60,6 +80,24 @@ export default {
 
       this.$apollo.queries.jobs.refetch({ statuses: scope });
     },
+    handlePageChange(page) {
+      const { startCursor, endCursor } = this.jobs.pageInfo;
+
+      if (page > this.pagination.currentPage) {
+        this.pagination = {
+          ...initialPaginationState,
+          nextPageCursor: endCursor,
+          currentPage: page,
+        };
+      } else {
+        this.pagination = {
+          last: GRAPHQL_PAGE_SIZE,
+          first: null,
+          prevPageCursor: startCursor,
+          currentPage: page,
+        };
+      }
+    },
   },
 };
 </script>
@@ -97,6 +135,16 @@ export default {
 
     <jobs-table-empty-state v-else-if="showEmptyState" />
 
-    <jobs-table v-else :jobs="jobs" />
+    <jobs-table v-else :jobs="jobs.list" />
+
+    <gl-pagination
+      v-if="showPaginationControls"
+      :value="pagination.currentPage"
+      :prev-page="prevPage"
+      :next-page="nextPage"
+      align="center"
+      class="gl-mt-3"
+      @input="handlePageChange"
+    />
   </div>
 </template>
diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js
index 9d1135e26c8442ceb29950a829d2ef6eb7b6f5e7..482d0df4e9a389ca6ee984244ad4b881a4bb461b 100644
--- a/spec/frontend/jobs/components/table/job_table_app_spec.js
+++ b/spec/frontend/jobs/components/table/job_table_app_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoader, GlAlert, GlEmptyState } from '@gitlab/ui';
+import { GlSkeletonLoader, GlAlert, GlEmptyState, GlPagination } from '@gitlab/ui';
 import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
 import VueApollo from 'vue-apollo';
 import createMockApollo from 'helpers/mock_apollo_helper';
@@ -25,6 +25,10 @@ describe('Job table app', () => {
   const findTabs = () => wrapper.findComponent(JobsTableTabs);
   const findAlert = () => wrapper.findComponent(GlAlert);
   const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+  const findPagination = () => wrapper.findComponent(GlPagination);
+
+  const findPrevious = () => findPagination().findAll('.page-item').at(0);
+  const findNext = () => findPagination().findAll('.page-item').at(1);
 
   const createMockApolloProvider = (handler) => {
     const requestHandlers = [[getJobsQuery, handler]];
@@ -32,8 +36,17 @@ describe('Job table app', () => {
     return createMockApollo(requestHandlers);
   };
 
-  const createComponent = (handler = successHandler, mountFn = shallowMount) => {
+  const createComponent = ({
+    handler = successHandler,
+    mountFn = shallowMount,
+    data = {},
+  } = {}) => {
     wrapper = mountFn(JobsTableApp, {
+      data() {
+        return {
+          ...data,
+        };
+      },
       provide: {
         projectPath,
       },
@@ -52,6 +65,7 @@ describe('Job table app', () => {
 
       expect(findSkeletonLoader().exists()).toBe(true);
       expect(findTable().exists()).toBe(false);
+      expect(findPagination().exists()).toBe(false);
     });
   });
 
@@ -65,9 +79,10 @@ describe('Job table app', () => {
     it('should display the jobs table with data', () => {
       expect(findTable().exists()).toBe(true);
       expect(findSkeletonLoader().exists()).toBe(false);
+      expect(findPagination().exists()).toBe(true);
     });
 
-    it('should retfech jobs query on fetchJobsByStatus event', async () => {
+    it('should refetch jobs query on fetchJobsByStatus event', async () => {
       jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
 
       expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
@@ -78,9 +93,72 @@ describe('Job table app', () => {
     });
   });
 
+  describe('pagination', () => {
+    it('should disable the next page button on the last page', async () => {
+      createComponent({
+        handler: successHandler,
+        mountFn: mount,
+        data: {
+          pagination: {
+            currentPage: 3,
+          },
+          jobs: {
+            pageInfo: {
+              hasPreviousPage: true,
+              startCursor: 'abc',
+              endCursor: 'bcd',
+            },
+          },
+        },
+      });
+
+      await wrapper.vm.$nextTick();
+
+      wrapper.setData({
+        jobs: {
+          pageInfo: {
+            hasNextPage: false,
+          },
+        },
+      });
+
+      await wrapper.vm.$nextTick();
+
+      expect(findPrevious().exists()).toBe(true);
+      expect(findNext().exists()).toBe(true);
+      expect(findNext().classes('disabled')).toBe(true);
+    });
+
+    it('should disable the previous page button on the first page', async () => {
+      createComponent({
+        handler: successHandler,
+        mountFn: mount,
+        data: {
+          pagination: {
+            currentPage: 1,
+          },
+          jobs: {
+            pageInfo: {
+              hasNextPage: true,
+              hasPreviousPage: false,
+              startCursor: 'abc',
+              endCursor: 'bcd',
+            },
+          },
+        },
+      });
+
+      await wrapper.vm.$nextTick();
+
+      expect(findPrevious().exists()).toBe(true);
+      expect(findPrevious().classes('disabled')).toBe(true);
+      expect(findNext().exists()).toBe(true);
+    });
+  });
+
   describe('error state', () => {
     it('should show an alert if there is an error fetching the data', async () => {
-      createComponent(failedHandler);
+      createComponent({ handler: failedHandler });
 
       await waitForPromises();
 
@@ -90,7 +168,7 @@ describe('Job table app', () => {
 
   describe('empty state', () => {
     it('should display empty state if there are no jobs and tab scope is null', async () => {
-      createComponent(emptyHandler, mount);
+      createComponent({ handler: emptyHandler, mountFn: mount });
 
       await waitForPromises();
 
@@ -99,7 +177,7 @@ describe('Job table app', () => {
     });
 
     it('should not display empty state if there are jobs and tab scope is not null', async () => {
-      createComponent(successHandler, mount);
+      createComponent({ handler: successHandler, mountFn: mount });
 
       await waitForPromises();