diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue
index 6176e15ffd4d9195305a7a6828defa4a807f2c7c..426ad150ea9a9a154a33b1d196f3182ecbd3c6b7 100644
--- a/app/assets/javascripts/packages/list/components/package_title.vue
+++ b/app/assets/javascripts/packages/list/components/package_title.vue
@@ -11,25 +11,25 @@ export default {
     MetadataItem,
   },
   props: {
-    packagesCount: {
+    count: {
       type: Number,
       required: false,
       default: null,
     },
-    packageHelpUrl: {
+    helpUrl: {
       type: String,
       required: true,
     },
   },
   computed: {
     showPackageCount() {
-      return Number.isInteger(this.packagesCount);
+      return Number.isInteger(this.count);
     },
     packageAmountText() {
-      return n__(`%d Package`, `%d Packages`, this.packagesCount);
+      return n__(`%d Package`, `%d Packages`, this.count);
     },
     infoMessages() {
-      return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }];
+      return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }];
     },
   },
   i18n: {
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue
index b9d922bf1cfa5d9746d5ee8ac5254fe24d1fee6f..4c5fb0ee7c9e33602eb676c332d00a7d48839a10 100644
--- a/app/assets/javascripts/packages/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue
@@ -8,8 +8,6 @@ import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
 import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
 import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
 import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
-import PackageSearch from './package_search.vue';
-import PackageTitle from './package_title.vue';
 import PackageList from './packages_list.vue';
 
 export default {
@@ -18,8 +16,38 @@ export default {
     GlLink,
     GlSprintf,
     PackageList,
-    PackageTitle,
-    PackageSearch,
+    PackageTitle: () =>
+      import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'),
+    PackageSearch: () =>
+      import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'),
+    InfrastructureTitle: () =>
+      import(
+        /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'
+      ),
+    InfrastructureSearch: () =>
+      import(
+        /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'
+      ),
+  },
+  inject: {
+    titleComponent: {
+      from: 'titleComponent',
+      default: 'PackageTitle',
+    },
+    searchComponent: {
+      from: 'searchComponent',
+      default: 'PackageSearch',
+    },
+    emptyPageTitle: {
+      from: 'emptyPageTitle',
+      default: s__('PackageRegistry|There are no packages yet'),
+    },
+    noResultsText: {
+      from: 'noResultsText',
+      default: s__(
+        'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
+      ),
+    },
   },
   computed: {
     ...mapState({
@@ -38,7 +66,7 @@ export default {
 
     emptyStateTitle() {
       return this.emptySearch
-        ? s__('PackageRegistry|There are no packages yet')
+        ? this.emptyPageTitle
         : s__('PackageRegistry|Sorry, your filter produced no results');
     },
   },
@@ -77,24 +105,21 @@ export default {
   },
   i18n: {
     widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
-    noResults: s__(
-      'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
-    ),
   },
 };
 </script>
 
 <template>
   <div>
-    <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" />
-    <package-search @update="requestPackagesList" />
+    <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" />
+    <component :is="searchComponent" @update="requestPackagesList" />
 
     <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
       <template #empty-state>
         <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
           <template #description>
             <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
-            <gl-sprintf v-else :message="$options.i18n.noResults">
+            <gl-sprintf v-else :message="noResultsText">
               <template #noPackagesLink="{ content }">
                 <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
               </template>
diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
index 58b09c1ebd151ba333a81a92c94f97f21fc328f2..2911cf70a33ca8648e720ddfe85a615c2a603323 100644
--- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js
+++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
@@ -1,11 +1,8 @@
 import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
 import Translate from '~/vue_shared/translate';
 import PackagesListApp from './components/packages_list_app.vue';
 import { createStore } from './stores';
 
-Vue.use(VueApollo);
 Vue.use(Translate);
 
 export default () => {
@@ -13,14 +10,9 @@ export default () => {
   const store = createStore();
   store.dispatch('setInitialState', el.dataset);
 
-  const apolloProvider = new VueApollo({
-    defaultClient: createDefaultClient(),
-  });
-
   return new Vue({
     el,
     store,
-    apolloProvider,
     components: {
       PackagesListApp,
     },
diff --git a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue
new file mode 100644
index 0000000000000000000000000000000000000000..105f7bbe1328fbe5dacc3a72dba71ab2b6a6f999
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+  name: 'PackageIconAndName',
+  components: {
+    GlIcon,
+  },
+};
+</script>
+
+<template>
+  <div class="gl-display-flex gl-align-items-center">
+    <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
+    <span><slot></slot></span>
+  </div>
+</template>
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
index 172b356227a6b872fa26b02457001c9825f4f532..4de4c191e512b4d3712476f8dc3e1582e724f34c 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -1,5 +1,5 @@
 <script>
-import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
 import ListItem from '~/vue_shared/components/registry/list_item.vue';
 import timeagoMixin from '~/vue_shared/mixins/timeago';
 import { getPackageTypeLabel } from '../utils';
@@ -11,7 +11,6 @@ export default {
   name: 'PackageListRow',
   components: {
     GlButton,
-    GlIcon,
     GlLink,
     GlSprintf,
     GlTruncate,
@@ -19,11 +18,23 @@ export default {
     PackagePath,
     PublishMethod,
     ListItem,
+    PackageIconAndName: () =>
+      import(/* webpackChunkName: 'package_registry_components' */ './package_icon_and_name.vue'),
+    InfrastructureIconAndName: () =>
+      import(
+        /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue'
+      ),
   },
   directives: {
     GlTooltip: GlTooltipDirective,
   },
   mixins: [timeagoMixin],
+  inject: {
+    iconComponent: {
+      from: 'iconComponent',
+      default: 'PackageIconAndName',
+    },
+  },
   props: {
     packageEntity: {
       type: Object,
@@ -94,10 +105,9 @@ export default {
           </gl-sprintf>
         </div>
 
-        <div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type">
-          <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
-          <span>{{ packageType }}</span>
-        </div>
+        <component :is="iconComponent" v-if="showPackageType">
+          {{ packageType }}
+        </component>
 
         <package-path v-if="hasProjectLink" :path="packageEntity.project_path" />
       </div>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3100a1a7296a1234441e3b345f13b0d1e95fd372
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+  name: 'InfrastructureIconAndName',
+  components: {
+    GlIcon,
+  },
+};
+</script>
+
+<template>
+  <div class="gl-display-flex gl-align-items-center">
+    <gl-icon name="infrastructure-registry" class="gl-ml-3 gl-mr-2" />
+    <span>{{ s__('InfrastructureRegistry|Terraform') }}</span>
+  </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2ed17208404f3b02f915058eaa1436f4cf70d328
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { LIST_KEY_PACKAGE_TYPE } from '~/packages/list/constants';
+import getTableHeaders from '~/packages/list/utils';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+export default {
+  components: { RegistrySearch, UrlSync },
+  computed: {
+    ...mapState({
+      isGroupPage: (state) => state.config.isGroupPage,
+      sorting: (state) => state.sorting,
+      filter: (state) => state.filter,
+    }),
+    sortableFields() {
+      return getTableHeaders(this.isGroupPage).filter((h) => h.orderBy !== LIST_KEY_PACKAGE_TYPE);
+    },
+  },
+  methods: {
+    ...mapActions(['setSorting', 'setFilter']),
+    updateSorting(newValue) {
+      this.setSorting(newValue);
+      this.$emit('update');
+    },
+  },
+};
+</script>
+
+<template>
+  <url-sync>
+    <template #default="{ updateQuery }">
+      <registry-search
+        :filter="filter"
+        :sorting="sorting"
+        :tokens="[]"
+        :sortable-fields="sortableFields"
+        @sorting:changed="updateSorting"
+        @filter:changed="setFilter"
+        @filter:submit="$emit('update')"
+        @query:changed="updateQuery"
+      />
+    </template>
+  </url-sync>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2a479c65d0ca4064c010d443095d459895191774
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue
@@ -0,0 +1,53 @@
+<script>
+import { s__, n__ } from '~/locale';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+export default {
+  name: 'InfrastructureTitle',
+  components: {
+    TitleArea,
+    MetadataItem,
+  },
+  props: {
+    count: {
+      type: Number,
+      required: false,
+      default: null,
+    },
+    helpUrl: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    showModuleCount() {
+      return Number.isInteger(this.count);
+    },
+    moduleAmountText() {
+      return n__(`%d Module`, `%d Modules`, this.count);
+    },
+    infoMessages() {
+      return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }];
+    },
+  },
+  i18n: {
+    LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'),
+    LIST_INTRO_TEXT: s__(
+      'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}',
+    ),
+  },
+};
+</script>
+
+<template>
+  <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
+    <template #metadata-amount>
+      <metadata-item
+        v-if="showModuleCount"
+        icon="infrastructure-registry"
+        :text="moduleAmountText"
+      />
+    </template>
+  </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
new file mode 100644
index 0000000000000000000000000000000000000000..88ee8a4200e97e63bd23763ebfdd92e57c7ba5e2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import { s__ } from '~/locale';
+import PackagesListApp from '~/packages/list/components/packages_list_app.vue';
+import { createStore } from '~/packages/list/stores';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+
+export default () => {
+  const el = document.getElementById('js-vue-packages-list');
+  const store = createStore();
+  store.dispatch('setInitialState', el.dataset);
+
+  return new Vue({
+    el,
+    store,
+    components: {
+      PackagesListApp,
+    },
+    provide: {
+      titleComponent: 'InfrastructureTitle',
+      searchComponent: 'InfrastructureSearch',
+      iconComponent: 'InfrastructureIconAndName',
+      emptyPageTitle: s__('InfrastructureRegistry|You have no Terraform modules in your project'),
+      noResultsText: s__(
+        'InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab.',
+      ),
+    },
+    render(createElement) {
+      return createElement('packages-list-app');
+    },
+  });
+};
diff --git a/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js
index c94782fdf1bf14cd7f8e133d102121e354d10246..dfb750eca41ea039e98a896055c8ac90f8c61b88 100644
--- a/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js
+++ b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js
@@ -1,3 +1,3 @@
-import initPackageList from '~/packages/list/packages_list_app_bundle';
+import initList from '~/packages_and_registries/infrastructure_registry/list_app_bundle';
 
-initPackageList();
+initList();
diff --git a/app/views/projects/packages/infrastructure_registry/index.html.haml b/app/views/projects/packages/infrastructure_registry/index.html.haml
index e8be9051275c145751ca904bbbb626f92de5c8bf..5a118997ff97a6b2400885c08403cefbe6845fc6 100644
--- a/app/views/projects/packages/infrastructure_registry/index.html.haml
+++ b/app/views/projects/packages/infrastructure_registry/index.html.haml
@@ -5,6 +5,6 @@
   .col-12
     #js-vue-packages-list{ data: { resource_id: @project.id,
       page_type: 'project',
-      empty_list_help_url: help_page_path('user/packages/package_registry/index'),
-      empty_list_illustration: image_path('illustrations/no-packages.svg'),
-      package_help_url: help_page_path('user/packages/index') } }
+      empty_list_help_url: help_page_path('user/infrastructure/index'),
+      empty_list_illustration: image_path('illustrations/empty-state/empty-terraform-register-lg.svg'),
+      package_help_url: help_page_path('user/infrastructure/index') } }
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 50c249a90f43d3b32e81d2b1af87cd664dccc525..c5bb6cb8737fe9fe1101996905640f0bf6c4aa9e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -91,6 +91,11 @@ msgid_plural "%d Approvals"
 msgstr[0] ""
 msgstr[1] ""
 
+msgid "%d Module"
+msgid_plural "%d Modules"
+msgstr[0] ""
+msgstr[1] ""
+
 msgid "%d Other"
 msgid_plural "%d Others"
 msgstr[0] ""
@@ -16905,6 +16910,21 @@ msgstr ""
 msgid "Infrastructure Registry"
 msgstr ""
 
+msgid "InfrastructureRegistry|Infrastructure Registry"
+msgstr ""
+
+msgid "InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}"
+msgstr ""
+
+msgid "InfrastructureRegistry|Terraform"
+msgstr ""
+
+msgid "InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab."
+msgstr ""
+
+msgid "InfrastructureRegistry|You have no Terraform modules in your project"
+msgstr ""
+
 msgid "Inherited"
 msgstr ""
 
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
index 93f4981a6a368412a0516dd975ca533a3e3bb808..07aba62fef6cfb7f9d3e4432e84fb569d673134a 100644
--- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -2,11 +2,11 @@
 
 exports[`packages_list_app renders 1`] = `
 <div>
-  <package-title-stub
-    packagehelpurl="foo"
+  <div
+    help-url="foo"
   />
    
-  <package-search-stub />
+  <div />
    
   <div>
     <section
diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js
index 9ece92321366f5fa96543880e2b8e50f37e76ea4..4de2dd0789e927b7264b80cdbd7012adb623d7dc 100644
--- a/spec/frontend/packages/list/components/packages_list_app_spec.js
+++ b/spec/frontend/packages/list/components/packages_list_app_spec.js
@@ -3,7 +3,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
 import Vuex from 'vuex';
 import createFlash from '~/flash';
 import * as commonUtils from '~/lib/utils/common_utils';
-import PackageSearch from '~/packages/list/components/package_search.vue';
 import PackageListApp from '~/packages/list/components/packages_list_app.vue';
 import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
 import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
@@ -26,10 +25,19 @@ describe('packages_list_app', () => {
   };
   const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' };
 
+  // we need to manually stub dynamic imported components because shallowMount is not able to stub them automatically. See: https://github.com/vuejs/vue-test-utils/issues/1279
+  const PackageSearch = { name: 'PackageSearch', template: '<div></div>' };
+  const PackageTitle = { name: 'PackageTitle', template: '<div></div>' };
+  const InfrastructureTitle = { name: 'InfrastructureTitle', template: '<div></div>' };
+  const InfrastructureSearch = { name: 'InfrastructureSearch', template: '<div></div>' };
+
   const emptyListHelpUrl = 'helpUrl';
   const findEmptyState = () => wrapper.find(GlEmptyState);
   const findListComponent = () => wrapper.find(PackageList);
   const findPackageSearch = () => wrapper.find(PackageSearch);
+  const findPackageTitle = () => wrapper.find(PackageTitle);
+  const findInfrastructureTitle = () => wrapper.find(InfrastructureTitle);
+  const findInfrastructureSearch = () => wrapper.find(InfrastructureSearch);
 
   const createStore = (filter = []) => {
     store = new Vuex.Store({
@@ -47,7 +55,7 @@ describe('packages_list_app', () => {
     store.dispatch = jest.fn();
   };
 
-  const mountComponent = () => {
+  const mountComponent = (provide) => {
     wrapper = shallowMount(PackageListApp, {
       localVue,
       store,
@@ -57,7 +65,12 @@ describe('packages_list_app', () => {
         PackageList,
         GlSprintf,
         GlLink,
+        PackageSearch,
+        PackageTitle,
+        InfrastructureTitle,
+        InfrastructureSearch,
       },
+      provide,
     });
   };
 
@@ -194,6 +207,31 @@ describe('packages_list_app', () => {
     });
   });
 
+  describe('Infrastructure config', () => {
+    it('defaults to package registry components', () => {
+      mountComponent();
+
+      expect(findPackageSearch().exists()).toBe(true);
+      expect(findPackageTitle().exists()).toBe(true);
+
+      expect(findInfrastructureTitle().exists()).toBe(false);
+      expect(findInfrastructureSearch().exists()).toBe(false);
+    });
+
+    it('mount different component based on the provided values', () => {
+      mountComponent({
+        titleComponent: 'InfrastructureTitle',
+        searchComponent: 'InfrastructureSearch',
+      });
+
+      expect(findPackageSearch().exists()).toBe(false);
+      expect(findPackageTitle().exists()).toBe(false);
+
+      expect(findInfrastructureTitle().exists()).toBe(true);
+      expect(findInfrastructureSearch().exists()).toBe(true);
+    });
+  });
+
   describe('delete alert handling', () => {
     const { location } = window.location;
     const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`;
diff --git a/spec/frontend/packages/list/components/packages_title_spec.js b/spec/frontend/packages/list/components/packages_title_spec.js
index 3716e8daa7cdb527bd1d9eb457851c531703e9dc..a17f72e313378cd6cdad43eb9bd43544356ef415 100644
--- a/spec/frontend/packages/list/components/packages_title_spec.js
+++ b/spec/frontend/packages/list/components/packages_title_spec.js
@@ -11,7 +11,7 @@ describe('PackageTitle', () => {
   const findTitleArea = () => wrapper.find(TitleArea);
   const findMetadataItem = () => wrapper.find(MetadataItem);
 
-  const mountComponent = (propsData = { packageHelpUrl: 'foo' }) => {
+  const mountComponent = (propsData = { helpUrl: 'foo' }) => {
     wrapper = shallowMount(PackageTitle, {
       store,
       propsData,
@@ -44,15 +44,15 @@ describe('PackageTitle', () => {
   });
 
   describe.each`
-    packagesCount | exist    | text
-    ${null}       | ${false} | ${''}
-    ${undefined}  | ${false} | ${''}
-    ${0}          | ${true}  | ${'0 Packages'}
-    ${1}          | ${true}  | ${'1 Package'}
-    ${2}          | ${true}  | ${'2 Packages'}
-  `('when packagesCount is $packagesCount metadata item', ({ packagesCount, exist, text }) => {
+    count        | exist    | text
+    ${null}      | ${false} | ${''}
+    ${undefined} | ${false} | ${''}
+    ${0}         | ${true}  | ${'0 Packages'}
+    ${1}         | ${true}  | ${'1 Package'}
+    ${2}         | ${true}  | ${'2 Packages'}
+  `('when count is $count metadata item', ({ count, exist, text }) => {
     beforeEach(() => {
-      mountComponent({ packagesCount, packageHelpUrl: 'foo' });
+      mountComponent({ count, helpUrl: 'foo' });
     });
 
     it(`is ${exist} that it exists`, () => {
diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
index 77095f7c6113bbe51a88e3b96cea9c5dfc8f15c1..03b98478f3e82542b4c4f1f1cef2d0f8213a33ef 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
@@ -51,20 +51,7 @@ exports[`packages_list_row renders 1`] = `
              
             <!---->
              
-            <div
-              class="d-flex align-items-center"
-              data-testid="package-type"
-            >
-              <gl-icon-stub
-                class="gl-ml-3 gl-mr-2"
-                name="package"
-                size="16"
-              />
-               
-              <span>
-                Maven
-              </span>
-            </div>
+            <div />
              
             <package-path-stub
               path="foo/bar/baz"
diff --git a/spec/frontend/packages/shared/components/package_icon_and_name_spec.js b/spec/frontend/packages/shared/components/package_icon_and_name_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c96a570a29c562d5c6c1e9dd13d5befa1c15971f
--- /dev/null
+++ b/spec/frontend/packages/shared/components/package_icon_and_name_spec.js
@@ -0,0 +1,32 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue';
+
+describe('PackageIconAndName', () => {
+  let wrapper;
+
+  const findIcon = () => wrapper.find(GlIcon);
+
+  const mountComponent = () => {
+    wrapper = shallowMount(PackageIconAndName, {
+      slots: {
+        default: 'test',
+      },
+    });
+  };
+
+  it('has an icon', () => {
+    mountComponent();
+
+    const icon = findIcon();
+
+    expect(icon.exists()).toBe(true);
+    expect(icon.props('name')).toBe('package');
+  });
+
+  it('renders the slot content', () => {
+    mountComponent();
+
+    expect(wrapper.text()).toBe('test');
+  });
+});
diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js
index 1c0ef7e353955bccfdfecdb1c90ce1c53aff8130..fd54cd0f25dc52365499833c8dbbea5f6d57c097 100644
--- a/spec/frontend/packages/shared/components/package_list_row_spec.js
+++ b/spec/frontend/packages/shared/components/package_list_row_spec.js
@@ -1,7 +1,9 @@
 import { shallowMount } from '@vue/test-utils';
+
 import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
 import PackagePath from '~/packages/shared/components/package_path.vue';
 import PackageTags from '~/packages/shared/components/package_tags.vue';
+
 import ListItem from '~/vue_shared/components/registry/list_item.vue';
 import { packageList } from '../../mock_data';
 
@@ -11,20 +13,30 @@ describe('packages_list_row', () => {
 
   const [packageWithoutTags, packageWithTags] = packageList;
 
+  const InfrastructureIconAndName = { name: 'InfrastructureIconAndName', template: '<div></div>' };
+  const PackageIconAndName = { name: 'PackageIconAndName', template: '<div></div>' };
+
   const findPackageTags = () => wrapper.find(PackageTags);
   const findPackagePath = () => wrapper.find(PackagePath);
   const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]');
-  const findPackageType = () => wrapper.find('[data-testid="package-type"]');
+  const findPackageIconAndName = () => wrapper.find(PackageIconAndName);
+  const findInfrastructureIconAndName = () => wrapper.find(InfrastructureIconAndName);
 
   const mountComponent = ({
     isGroup = false,
     packageEntity = packageWithoutTags,
     showPackageType = true,
     disableDelete = false,
+    provide,
   } = {}) => {
     wrapper = shallowMount(PackagesListRow, {
       store,
-      stubs: { ListItem },
+      provide,
+      stubs: {
+        ListItem,
+        InfrastructureIconAndName,
+        PackageIconAndName,
+      },
       propsData: {
         packageLink: 'foo',
         packageEntity,
@@ -72,13 +84,13 @@ describe('packages_list_row', () => {
     it('shows the type when set', () => {
       mountComponent();
 
-      expect(findPackageType().exists()).toBe(true);
+      expect(findPackageIconAndName().exists()).toBe(true);
     });
 
     it('does not show the type when not set', () => {
       mountComponent({ showPackageType: false });
 
-      expect(findPackageType().exists()).toBe(false);
+      expect(findPackageIconAndName().exists()).toBe(false);
     });
   });
 
@@ -113,4 +125,25 @@ describe('packages_list_row', () => {
       expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]);
     });
   });
+
+  describe('Infrastructure config', () => {
+    it('defaults to package registry components', () => {
+      mountComponent();
+
+      expect(findPackageIconAndName().exists()).toBe(true);
+      expect(findInfrastructureIconAndName().exists()).toBe(false);
+    });
+
+    it('mounts different component based on the provided values', () => {
+      mountComponent({
+        provide: {
+          iconComponent: 'InfrastructureIconAndName',
+        },
+      });
+
+      expect(findPackageIconAndName().exists()).toBe(false);
+
+      expect(findInfrastructureIconAndName().exists()).toBe(true);
+    });
+  });
 });
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef26c729691713590e93c4ad93a0633fbe667699
--- /dev/null
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js
@@ -0,0 +1,28 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue';
+
+describe('InfrastructureIconAndName', () => {
+  let wrapper;
+
+  const findIcon = () => wrapper.find(GlIcon);
+
+  const mountComponent = () => {
+    wrapper = shallowMount(InfrastructureIconAndName, {});
+  };
+
+  it('has an icon', () => {
+    mountComponent();
+
+    const icon = findIcon();
+
+    expect(icon.exists()).toBe(true);
+    expect(icon.props('name')).toBe('infrastructure-registry');
+  });
+
+  it('has the type fixed to terraform', () => {
+    mountComponent();
+
+    expect(wrapper.text()).toBe('Terraform');
+  });
+});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..93d560fd08df7782e62291af770110ca59fc074f
--- /dev/null
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js
@@ -0,0 +1,129 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Infrastructure Search', () => {
+  let wrapper;
+  let store;
+
+  const sortableFields = () => [
+    { orderBy: 'name', label: 'Name' },
+    { orderBy: 'project_path', label: 'Project' },
+    { orderBy: 'version', label: 'Version' },
+    { orderBy: 'created_at', label: 'Published' },
+  ];
+
+  const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
+  const findUrlSync = () => wrapper.findComponent(UrlSync);
+
+  const createStore = (isGroupPage) => {
+    const state = {
+      config: {
+        isGroupPage,
+      },
+      sorting: {
+        orderBy: 'version',
+        sort: 'desc',
+      },
+      filter: [],
+    };
+    store = new Vuex.Store({
+      state,
+    });
+    store.dispatch = jest.fn();
+  };
+
+  const mountComponent = (isGroupPage = false) => {
+    createStore(isGroupPage);
+
+    wrapper = shallowMount(component, {
+      localVue,
+      store,
+      stubs: {
+        UrlSync,
+      },
+    });
+  };
+
+  afterEach(() => {
+    wrapper.destroy();
+    wrapper = null;
+  });
+
+  it('has a registry search component', () => {
+    mountComponent();
+
+    expect(findRegistrySearch().exists()).toBe(true);
+    expect(findRegistrySearch().props()).toMatchObject({
+      filter: store.state.filter,
+      sorting: store.state.sorting,
+      tokens: [],
+      sortableFields: sortableFields(),
+    });
+  });
+
+  it.each`
+    isGroupPage | page
+    ${false}    | ${'project'}
+    ${true}     | ${'group'}
+  `('in a $page page binds the right props', ({ isGroupPage }) => {
+    mountComponent(isGroupPage);
+
+    expect(findRegistrySearch().props()).toMatchObject({
+      filter: store.state.filter,
+      sorting: store.state.sorting,
+      tokens: [],
+      sortableFields: sortableFields(),
+    });
+  });
+
+  it('on sorting:changed emits update event and calls vuex setSorting', () => {
+    const payload = { sort: 'foo' };
+
+    mountComponent();
+
+    findRegistrySearch().vm.$emit('sorting:changed', payload);
+
+    expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload);
+    expect(wrapper.emitted('update')).toEqual([[]]);
+  });
+
+  it('on filter:changed calls vuex setFilter', () => {
+    const payload = ['foo'];
+
+    mountComponent();
+
+    findRegistrySearch().vm.$emit('filter:changed', payload);
+
+    expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload);
+  });
+
+  it('on filter:submit emits update event', () => {
+    mountComponent();
+
+    findRegistrySearch().vm.$emit('filter:submit');
+
+    expect(wrapper.emitted('update')).toEqual([[]]);
+  });
+
+  it('has a UrlSync component', () => {
+    mountComponent();
+
+    expect(findUrlSync().exists()).toBe(true);
+  });
+
+  it('on query:changed calls updateQuery from UrlSync', () => {
+    jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
+
+    mountComponent();
+
+    findRegistrySearch().vm.$emit('query:changed');
+
+    expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
+  });
+});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..db6e175b0549a302ab25aca6b16101f41239acbb
--- /dev/null
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js
@@ -0,0 +1,75 @@
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+describe('Infrastructure Title', () => {
+  let wrapper;
+  let store;
+
+  const findTitleArea = () => wrapper.find(TitleArea);
+  const findMetadataItem = () => wrapper.find(MetadataItem);
+
+  const mountComponent = (propsData = { helpUrl: 'foo' }) => {
+    wrapper = shallowMount(component, {
+      store,
+      propsData,
+      stubs: {
+        TitleArea,
+      },
+    });
+  };
+
+  afterEach(() => {
+    wrapper.destroy();
+    wrapper = null;
+  });
+
+  describe('title area', () => {
+    it('exists', () => {
+      mountComponent();
+
+      expect(findTitleArea().exists()).toBe(true);
+    });
+
+    it('has the correct props', () => {
+      mountComponent();
+
+      expect(findTitleArea().props()).toMatchObject({
+        title: 'Infrastructure Registry',
+        infoMessages: [
+          {
+            text: 'Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}',
+            link: 'foo',
+          },
+        ],
+      });
+    });
+  });
+
+  describe.each`
+    count        | exist    | text
+    ${null}      | ${false} | ${''}
+    ${undefined} | ${false} | ${''}
+    ${0}         | ${true}  | ${'0 Modules'}
+    ${1}         | ${true}  | ${'1 Module'}
+    ${2}         | ${true}  | ${'2 Modules'}
+  `('when count is $count metadata item', ({ count, exist, text }) => {
+    beforeEach(() => {
+      mountComponent({ count, helpUrl: 'foo' });
+    });
+
+    it(`is ${exist} that it exists`, () => {
+      expect(findMetadataItem().exists()).toBe(exist);
+    });
+
+    if (exist) {
+      it('has the correct props', () => {
+        expect(findMetadataItem().props()).toMatchObject({
+          icon: 'infrastructure-registry',
+          text,
+        });
+      });
+    }
+  });
+});