diff --git a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue
index c534f184abc31b17560b8169815be5a6fc5727d5..4672e67d9e636b68123ffe17976afde30026fbae 100644
--- a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue
+++ b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue
@@ -1,16 +1,33 @@
 <script>
-import SafeHtml from '~/vue_shared/directives/safe_html';
+import { GlTab, GlTabs } from '@gitlab/ui';
+import { __ } from '~/locale';
+import CiResourceReadme from './ci_resource_readme.vue';
 
 export default {
-  directives: { SafeHtml },
+  components: {
+    CiResourceReadme,
+    GlTab,
+    GlTabs,
+  },
   props: {
-    readmeHtml: {
-      required: true,
+    resourceId: {
       type: String,
+      required: true,
+    },
+  },
+  i18n: {
+    tabs: {
+      readme: __('Readme'),
     },
   },
 };
 </script>
+
 <template>
-  <div v-safe-html="readmeHtml"></div>
+  <gl-tabs>
+    <gl-tab :title="$options.i18n.tabs.readme" lazy>
+      <ci-resource-readme :resource-id="resourceId" />
+    </gl-tab>
+  </gl-tabs>
 </template>
+<style></style>
diff --git a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue
index da4745dcdb8e847c8479f4c5c2de4051625239da..6673785ffd2b376218ad68476cc88bd1ce22e227 100644
--- a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue
+++ b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue
@@ -72,7 +72,7 @@ export default {
 };
 </script>
 <template>
-  <div class="gl-border-b">
+  <div>
     <ci-resource-header-skeleton-loader v-if="isLoadingSharedData" class="gl-py-5" />
     <div v-else class="gl-display-flex gl-py-5">
       <gl-avatar-link :href="resource.webPath">
diff --git a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d473833869d517c8bc21f918c3108d7be95a537c
--- /dev/null
+++ b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import getCiCatalogResourceReadme from '../../graphql/queries/get_ci_catalog_resource_readme.query.graphql';
+
+export default {
+  components: {
+    GlLoadingIcon,
+  },
+  directives: { SafeHtml },
+  props: {
+    resourceId: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      readmeHtml: null,
+    };
+  },
+  apollo: {
+    readmeHtml: {
+      query: getCiCatalogResourceReadme,
+      variables() {
+        return {
+          id: this.resourceId,
+        };
+      },
+      update(data) {
+        return data?.ciCatalogResource?.readmeHtml || null;
+      },
+      error() {
+        createAlert({ message: this.$options.i18n.loadingError });
+      },
+    },
+  },
+  computed: {
+    isLoading() {
+      return this.$apollo.queries.readmeHtml.loading;
+    },
+  },
+  i18n: {
+    loadingError: __("There was a problem loading this project's readme content."),
+  },
+};
+</script>
+<template>
+  <div>
+    <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
+    <div v-else v-safe-html="readmeHtml"></div>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue b/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue
index 40a0c9c56be040a109a77c0d4e4d030d04ed16fe..da2c73be900bc29914f1e6ebfd3151dfe3f84f53 100644
--- a/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue
+++ b/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue
@@ -1,5 +1,5 @@
 <script>
-import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState } from '@gitlab/ui';
 import { s__ } from '~/locale';
 import { createAlert } from '~/alert';
 import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -14,7 +14,6 @@ export default {
     CiResourceDetails,
     CiResourceHeader,
     GlEmptyState,
-    GlLoadingIcon,
   },
   inject: ['ciCatalogPath'],
   data() {
@@ -104,8 +103,7 @@ export default {
         :pipeline-status="pipelineStatus"
         :resource="resourceSharedData"
       />
-      <gl-loading-icon v-if="isLoadingDetails" size="lg" class="gl-mt-5" />
-      <ci-resource-details v-else :readme-html="resourceAdditionalDetails.readmeHtml" />
+      <ci-resource-details :resource-id="graphQLId" />
     </div>
   </div>
 </template>
diff --git a/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql
index e0549b9feab9161d7911dcac9b39bd9f4c37428e..382d38667953ce9dea74028806dc4ecb8fd38020 100644
--- a/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql
+++ b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql
@@ -1,7 +1,6 @@
 query getCiCatalogResourceDetails($id: CiCatalogResourceID!) {
   ciCatalogResource(id: $id) {
     id
-    readmeHtml
     openIssuesCount
     openMergeRequestsCount
     versions(first: 1) {
diff --git a/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..6b3d0cdcfc70e1b9f19565afc1226ca0851c4d83
--- /dev/null
+++ b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql
@@ -0,0 +1,6 @@
+query getCiCatalogResourceReadme($id: CiCatalogResourceID!) {
+  ciCatalogResource(id: $id) {
+    id
+    readmeHtml
+  }
+}
diff --git a/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js b/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js
index 13dc6896ae365756c62054056df7b45e4e0264e1..b7efc7f2a7dd367953c048472df9ca59ca5f494b 100644
--- a/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js
+++ b/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js
@@ -1,10 +1,14 @@
+import { GlTabs, GlTab } from '@gitlab/ui';
 import { shallowMount } from '@vue/test-utils';
 import CiResourceDetails from 'ee/ci/catalog/components/details/ci_resource_details.vue';
+import CiResourceReadme from 'ee/ci/catalog/components/details/ci_resource_readme.vue';
 
 describe('CiResourceDetails', () => {
   let wrapper;
 
-  const defaultProps = { readmeHtml: '<h1>Hello world</h1>' };
+  const defaultProps = {
+    resourceId: 'gid://gitlab/Ci::Catalog::Resource/1',
+  };
 
   const createComponent = ({ props = {} } = {}) => {
     wrapper = shallowMount(CiResourceDetails, {
@@ -12,16 +16,37 @@ describe('CiResourceDetails', () => {
         ...defaultProps,
         ...props,
       },
+      stubs: {
+        GlTabs,
+      },
     });
   };
+  const findAllTabs = () => wrapper.findAllComponents(GlTab);
+  const findCiResourceReadme = () => wrapper.findComponent(CiResourceReadme);
+
+  beforeEach(() => {
+    createComponent();
+  });
+
+  describe('tabs', () => {
+    it('renders the right number of tabs', () => {
+      expect(findAllTabs()).toHaveLength(1);
+    });
+
+    it('renders the readme tab as default', () => {
+      expect(findCiResourceReadme().exists()).toBe(true);
+    });
 
-  describe('when mounted', () => {
-    beforeEach(() => {
-      createComponent();
+    it('passes lazy attribute to all tabs', () => {
+      findAllTabs().wrappers.forEach((tab) => {
+        expect(tab.attributes().lazy).not.toBeUndefined();
+      });
     });
 
-    it('renders the received HTML', () => {
-      expect(wrapper.html()).toContain(defaultProps.readmeHtml);
+    describe('readme tab', () => {
+      it('passes the right props to the readme component', () => {
+        expect(findCiResourceReadme().props().resourceId).toBe(defaultProps.resourceId);
+      });
     });
   });
 });
diff --git a/ee/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js b/ee/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..d8af6ccdd8347ad72171d18d267d8bb6df9199a7
--- /dev/null
+++ b/ee/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js
@@ -0,0 +1,96 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CiResourceReadme from 'ee/ci/catalog/components/details/ci_resource_readme.vue';
+import getCiCatalogResourceReadme from 'ee/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+
+jest.mock('~/alert');
+
+Vue.use(VueApollo);
+
+const readmeHtml = '<h1>This is a readme file</h1>';
+const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1';
+
+describe('CiResourceReadme', () => {
+  let wrapper;
+  let mockReadmeResponse;
+
+  const readmeMockData = {
+    data: {
+      ciCatalogResource: {
+        id: resourceId,
+        readmeHtml,
+      },
+    },
+  };
+
+  const defaultProps = { resourceId };
+
+  const createComponent = ({ props = {} } = {}) => {
+    const handlers = [[getCiCatalogResourceReadme, mockReadmeResponse]];
+
+    wrapper = shallowMountExtended(CiResourceReadme, {
+      propsData: {
+        ...defaultProps,
+        ...props,
+      },
+      apolloProvider: createMockApollo(handlers),
+    });
+  };
+
+  const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+  beforeEach(() => {
+    mockReadmeResponse = jest.fn();
+  });
+
+  describe('when loading', () => {
+    beforeEach(() => {
+      mockReadmeResponse.mockResolvedValue(readmeMockData);
+      createComponent();
+    });
+
+    it('renders only a loading icon', () => {
+      expect(findLoadingIcon().exists()).toBe(true);
+      expect(wrapper.html()).not.toContain(readmeHtml);
+    });
+  });
+
+  describe('when mounted', () => {
+    beforeEach(async () => {
+      mockReadmeResponse.mockResolvedValue(readmeMockData);
+
+      createComponent();
+      await waitForPromises();
+    });
+
+    it('renders only the received HTML', () => {
+      expect(findLoadingIcon().exists()).toBe(false);
+      expect(wrapper.html()).toContain(readmeHtml);
+    });
+
+    it('does not render an error', () => {
+      expect(createAlert).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('when there is an error loading the readme', () => {
+    beforeEach(async () => {
+      mockReadmeResponse.mockRejectedValue({ errors: [] });
+
+      createComponent();
+      await waitForPromises();
+    });
+
+    it('calls the createAlert function to show an error', () => {
+      expect(createAlert).toHaveBeenCalled();
+      expect(createAlert).toHaveBeenCalledWith({
+        message: "There was a problem loading this project's readme content.",
+      });
+    });
+  });
+});
diff --git a/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js b/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js
index ef90ddbb5f865e1a5ff2224618dd866a13fd99a0..2bd5773f706568e8c55f0984a0bd0795d1f8b3e8 100644
--- a/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js
+++ b/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js
@@ -1,11 +1,11 @@
 import Vue from 'vue';
 import VueApollo from 'vue-apollo';
 import VueRouter from 'vue-router';
-import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState } from '@gitlab/ui';
 import { shallowMount } from '@vue/test-utils';
 import createMockApollo from 'helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
-import { cacheConfig } from 'ee/ci/catalog/graphql/settings';
+import { CI_CATALOG_RESOURCE_TYPE, cacheConfig } from 'ee/ci/catalog/graphql/settings';
 
 import getCiCatalogResourceSharedData from 'ee/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql';
 import getCiCatalogResourceDetails from 'ee/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql';
@@ -17,6 +17,7 @@ import CiResourceHeaderSkeletonLoader from 'ee/ci/catalog/components/details/ci_
 
 import { createRouter } from 'ee/ci/catalog/router/index';
 import { CI_RESOURCE_DETAILS_PAGE_NAME } from 'ee/ci/catalog/router/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
 import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock';
 
 Vue.use(VueApollo);
@@ -41,7 +42,6 @@ describe('CiResourceDetailsPage', () => {
   const findDetailsComponent = () => wrapper.findComponent(CiResourceDetails);
   const findHeaderComponent = () => wrapper.findComponent(CiResourceHeader);
   const findEmptyState = () => wrapper.findComponent(GlEmptyState);
-  const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
   const findHeaderSkeletonLoader = () => wrapper.findComponent(CiResourceHeaderSkeletonLoader);
 
   const createComponent = ({ props = {} } = {}) => {
@@ -89,8 +89,7 @@ describe('CiResourceDetailsPage', () => {
         createComponent();
       });
 
-      it('renders only the details loading state', () => {
-        expect(findLoadingIcon().exists()).toBe(true);
+      it('renders the header skeleton loader', () => {
         expect(findHeaderSkeletonLoader().exists()).toBe(false);
       });
 
@@ -111,11 +110,11 @@ describe('CiResourceDetailsPage', () => {
         createComponent();
       });
 
-      it('renders all loading states', () => {
-        expect(findLoadingIcon().exists()).toBe(true);
+      it('does not render the header skeleton', () => {
+        expect(findHeaderSkeletonLoader().exists()).toBe(false);
       });
 
-      it('passes down the loading state to the header component', () => {
+      it('passes all loading state to the header component as true', () => {
         expect(findHeaderComponent().props()).toMatchObject({
           isLoadingDetails: true,
           isLoadingSharedData: true,
@@ -150,8 +149,8 @@ describe('CiResourceDetailsPage', () => {
       await waitForPromises();
     });
 
-    it('does not render a loading icon', () => {
-      expect(findLoadingIcon().exists()).toBe(false);
+    it('does not render the header skeleton loader', () => {
+      expect(findHeaderSkeletonLoader().exists()).toBe(false);
     });
 
     describe('Catalog header', () => {
@@ -179,7 +178,7 @@ describe('CiResourceDetailsPage', () => {
 
       it('passes expected props', () => {
         expect(findDetailsComponent().props()).toEqual({
-          readmeHtml: defaultAdditionalData.readmeHtml,
+          resourceId: convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, defaultAdditionalData.id),
         });
       });
     });
diff --git a/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js b/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js
index e720829065b7664ca9b9288102b9366800126889..e4a3d05d0985233e12704423f860f39ab41e2600 100644
--- a/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js
+++ b/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js
@@ -74,6 +74,7 @@ describe('CiResourcesPage', () => {
 
         await createComponent();
       });
+
       it('renders the empty state', () => {
         expect(findLoadingState().exists()).toBe(false);
         expect(findEmptyState().exists()).toBe(true);
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index b7b88999dd990b45e2736a48a5d41fcb82a1ede6..80c8ab86e7184822ef656e79ae6985ff03fe2275 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -38757,6 +38757,9 @@ msgstr ""
 msgid "Read their documentation."
 msgstr ""
 
+msgid "Readme"
+msgstr ""
+
 msgid "Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project."
 msgstr ""
 
@@ -48003,6 +48006,9 @@ msgstr ""
 msgid "There was a problem handling the pipeline data."
 msgstr ""
 
+msgid "There was a problem loading this project's readme content."
+msgstr ""
+
 msgid "There was a problem sending the confirmation email"
 msgstr ""