diff --git a/apps/settings/js/vue-4.js b/apps/settings/js/vue-4.js
index b2e85769945182a862fdcfa3765efb9df5195c89..be21d10b35e1c81cbd6ac945dcb729e6a53222e0 100644
Binary files a/apps/settings/js/vue-4.js and b/apps/settings/js/vue-4.js differ
diff --git a/apps/settings/js/vue-4.js.map b/apps/settings/js/vue-4.js.map
index d9b80b7ad159a28f084d023b5820a6e481b3a0a7..d78ea55117994640840113ac4e5f206d6defba58 100644
Binary files a/apps/settings/js/vue-4.js.map and b/apps/settings/js/vue-4.js.map differ
diff --git a/apps/settings/js/vue-5.js b/apps/settings/js/vue-5.js
index 98317284ec1e54385e20c5871e513deb7359d6eb..8eb491497f46c411f12a9d9aa142e2dd6d51631a 100644
Binary files a/apps/settings/js/vue-5.js and b/apps/settings/js/vue-5.js differ
diff --git a/apps/settings/js/vue-5.js.map b/apps/settings/js/vue-5.js.map
index d2fa0760e74879ab5c9f644dc70967065f332a3e..9f67448e32291599e495d78b269469de3c34ae21 100644
Binary files a/apps/settings/js/vue-5.js.map and b/apps/settings/js/vue-5.js.map differ
diff --git a/apps/settings/js/vue-settings-apps-users-management.js b/apps/settings/js/vue-settings-apps-users-management.js
index 2e1fd3a3c9f099e0d01ae508aa068b10fb2fad9c..5b9b5beee627f3c98d599d12ecea191327c52951 100644
Binary files a/apps/settings/js/vue-settings-apps-users-management.js and b/apps/settings/js/vue-settings-apps-users-management.js differ
diff --git a/apps/settings/js/vue-settings-apps-users-management.js.map b/apps/settings/js/vue-settings-apps-users-management.js.map
index 7e8e035706ca0785ab2561166f0a57e74bcd9696..bfaa3be2d04ab982c6dfa9451f2db0cf62ea42d5 100644
Binary files a/apps/settings/js/vue-settings-apps-users-management.js.map and b/apps/settings/js/vue-settings-apps-users-management.js.map differ
diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue
index f03d7c5a6947b7938c2458a45b380f101fa61257..a406f6b8ff63e81d44551a6a86118d9598e5e576 100644
--- a/apps/settings/src/components/AppList.vue
+++ b/apps/settings/src/components/AppList.vue
@@ -96,11 +96,9 @@
 </template>
 
 <script>
-import pLimit from 'p-limit'
-
 import AppItem from './AppList/AppItem'
 import PrefixMixin from './PrefixMixin'
-import recommended from '../recommendedApps'
+import pLimit from 'p-limit'
 
 export default {
 	name: 'AppList',
@@ -131,26 +129,26 @@ export default {
 					return OC.Util.naturalSortCompare(sortStringA, sortStringB)
 				})
 
-			switch (this.category) {
-			case 'installed':
+			if (this.category === 'installed') {
 				return apps.filter(app => app.installed)
-			case 'recommended':
-				return apps.filter(app => recommended.includes(app.id))
-			case 'enabled':
+			}
+			if (this.category === 'enabled') {
 				return apps.filter(app => app.active && app.installed)
-			case 'disabled':
+			}
+			if (this.category === 'disabled') {
 				return apps.filter(app => !app.active && app.installed)
-			case 'app-bundles':
+			}
+			if (this.category === 'app-bundles') {
 				return apps.filter(app => app.bundles)
-			case 'updates':
+			}
+			if (this.category === 'updates') {
 				return apps.filter(app => app.update)
-			default:
-				// filter app store categories
-				return apps.filter(app => {
-					return app.appstore && app.category !== undefined
-							&& (app.category === this.category || app.category.indexOf(this.category) > -1)
-				})
 			}
+			// filter app store categories
+			return apps.filter(app => {
+				return app.appstore && app.category !== undefined
+					&& (app.category === this.category || app.category.indexOf(this.category) > -1)
+			})
 		},
 		bundles() {
 			return this.$store.getters.getServerData.bundles.filter(bundle => this.bundleApps(bundle.id).length > 0)
@@ -177,7 +175,7 @@ export default {
 			return !this.useListView && !this.useBundleView
 		},
 		useListView() {
-			return ['installed', 'recommended', 'enabled', 'disabled', 'updates'].includes(this.category)
+			return (this.category === 'installed' || this.category === 'enabled' || this.category === 'disabled' || this.category === 'updates')
 		},
 		useBundleView() {
 			return (this.category === 'app-bundles')
@@ -198,24 +196,6 @@ export default {
 			}
 		}
 	},
-	mounted() {
-		if (this.category === 'recommended' && 'download' in this.$route.query) {
-			const limit = pLimit(1)
-			const installing = this.apps
-				.filter(app => !app.active && app.canInstall)
-				.map(app => limit(() => this.$store.dispatch('enableApp', { appId: app.id, groups: [] })))
-			console.debug(`installing ${installing.length} recommended apps`)
-			Promise.all(installing)
-				.then(() => {
-					console.info('recommended apps installed')
-
-					if ('returnTo' in this.$route.query) {
-						window.location = this.$route.query.returnTo
-					}
-				})
-				.catch(e => console.error('could not install recommended apps', e))
-		}
-	},
 	methods: {
 		toggleBundle(id) {
 			if (this.allBundlesEnabled(id)) {
diff --git a/apps/settings/src/views/Apps.vue b/apps/settings/src/views/Apps.vue
index a2c4cae825d0cdf02b153c4f92735e0a6e0c6185..2e0649f7f56555c5f350990ee3890377796301f6 100644
--- a/apps/settings/src/views/Apps.vue
+++ b/apps/settings/src/views/Apps.vue
@@ -31,10 +31,7 @@
 			</ul>
 		</AppNavigation>
 		<AppContent class="app-settings-content" :class="{ 'icon-loading': loadingList }">
-			<AppList v-if="!loadingList"
-				:category="category"
-				:app="currentApp"
-				:search="searchQuery" />
+			<AppList :category="category" :app="currentApp" :search="searchQuery" />
 		</AppContent>
 		<AppSidebar v-if="id && currentApp" @close="hideAppDetails">
 			<AppDetails :category="category" :app="currentApp" />
@@ -136,21 +133,13 @@ export default {
 					icon: 'icon-category-installed',
 					text: t('settings', 'Your apps')
 				},
-				{
-					id: 'app-category-recommended',
-					classes: [],
-					router: { name: 'apps-category', params: { category: 'recommended' } },
-					icon: 'icon-category-installed',
-					text: t('settings', 'Recommended apps')
-				},
 				{
 					id: 'app-category-enabled',
 					classes: [],
 					icon: 'icon-category-enabled',
 					router: { name: 'apps-category', params: { category: 'enabled' } },
 					text: t('settings', 'Active apps')
-				},
-				{
+				}, {
 					id: 'app-category-disabled',
 					classes: [],
 					icon: 'icon-category-disabled',
diff --git a/core/Controller/RecommendedAppsController.php b/core/Controller/RecommendedAppsController.php
new file mode 100644
index 0000000000000000000000000000000000000000..9f3f6187d29184c03bb46ba0c6e4a4b42cb26eec
--- /dev/null
+++ b/core/Controller/RecommendedAppsController.php
@@ -0,0 +1,53 @@
+<?php declare(strict_types=1);
+
+/**
+ * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace OC\Core\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
+use OCP\AppFramework\Http\Response;
+use OCP\AppFramework\Http\StandaloneTemplateResponse;
+use OCP\IInitialStateService;
+use OCP\IRequest;
+
+class RecommendedAppsController extends Controller {
+
+	/** @var IInitialStateService */
+	private $initialStateService;
+
+	public function __construct(IRequest $request,
+								IInitialStateService $initialStateService) {
+		parent::__construct('core', $request);
+		$this->initialStateService = $initialStateService;
+	}
+
+	/**
+	 * @NoCSRFRequired
+	 * @return Response
+	 */
+	public function index(): Response {
+		$this->initialStateService->provideInitialState('core', 'defaultPageUrl', \OC_Util::getDefaultPageUrl());
+		return new StandaloneTemplateResponse($this->appName, 'recommendedapps', [], 'guest');
+	}
+
+}
diff --git a/core/Controller/SetupController.php b/core/Controller/SetupController.php
index 1d83b0f42b59cd0dfd6aa2147394045723101bf8..302edccf7a64df7fec97c2dc2d8d912a5c0f569c 100644
--- a/core/Controller/SetupController.php
+++ b/core/Controller/SetupController.php
@@ -123,7 +123,7 @@ class SetupController {
 
 		if ($installRecommended) {
 			$urlGenerator = \OC::$server->getURLGenerator();
-			$location = $urlGenerator->getAbsoluteURL('/index.php/settings/apps/recommended?download&returnTo=' . urlencode(\OC_Util::getDefaultPageUrl()));
+			$location = $urlGenerator->getAbsoluteURL('index.php/core/apps/recommended');
 			header('Location: ' . $location);
 			exit();
 		}
diff --git a/core/js/dist/login.js b/core/js/dist/login.js
index 5ea0190122eac45bf3ff02acb483f2f05b10fd97..f0c2f5ac6949cf78c3832da9484cac6604db5c79 100644
Binary files a/core/js/dist/login.js and b/core/js/dist/login.js differ
diff --git a/core/js/dist/login.js.map b/core/js/dist/login.js.map
index 00070f2b94d8ef9d932a3342afd439bbbc8544b9..52f80a252a0566094aa276ba69d977158f9972e9 100644
Binary files a/core/js/dist/login.js.map and b/core/js/dist/login.js.map differ
diff --git a/core/js/dist/main.js b/core/js/dist/main.js
index 25ab4584a5f9cba1f35e8088c282209421866354..0165a44d4ffb15aad8169b0578d2434335108390 100644
Binary files a/core/js/dist/main.js and b/core/js/dist/main.js differ
diff --git a/core/js/dist/main.js.map b/core/js/dist/main.js.map
index 41ce56dd140000f7300d86a5ccb4df8b0ba8f507..ce6ec5de97b75005520a670fdb20aae8ad447337 100644
Binary files a/core/js/dist/main.js.map and b/core/js/dist/main.js.map differ
diff --git a/core/js/dist/maintenance.js b/core/js/dist/maintenance.js
index 71d3594216f9bd415f10ef19f863e4e5019e6a22..bf7d88665b794de03a11de3174c6b92bc0c13946 100644
Binary files a/core/js/dist/maintenance.js and b/core/js/dist/maintenance.js differ
diff --git a/core/js/dist/maintenance.js.map b/core/js/dist/maintenance.js.map
index f94bbff48beb962c64817ad0acb61740e8d74d18..27d2f3d16b041c94ab35ea36be8197bcb65acc5b 100644
Binary files a/core/js/dist/maintenance.js.map and b/core/js/dist/maintenance.js.map differ
diff --git a/core/js/dist/recommendedapps.js b/core/js/dist/recommendedapps.js
new file mode 100644
index 0000000000000000000000000000000000000000..105c68c3cb2a5615046ce4cabed2ba326b83d5ea
Binary files /dev/null and b/core/js/dist/recommendedapps.js differ
diff --git a/core/js/dist/recommendedapps.js.map b/core/js/dist/recommendedapps.js.map
new file mode 100644
index 0000000000000000000000000000000000000000..a57245548a9c9b2de6860a4352be05d5b28fb31e
Binary files /dev/null and b/core/js/dist/recommendedapps.js.map differ
diff --git a/core/routes.php b/core/routes.php
index f88bab1d539efb63bece1ede0ece7827aeb2f713..5fb13bc298afccf1fc9addf73ec6a0f2a7bf5721 100644
--- a/core/routes.php
+++ b/core/routes.php
@@ -74,6 +74,7 @@ $application->registerRoutes($this, [
 		['name' => 'OCJS#getConfig', 'url' => '/core/js/oc.js', 'verb' => 'GET'],
 		['name' => 'Preview#getPreviewByFileId', 'url' => '/core/preview', 'verb' => 'GET'],
 		['name' => 'Preview#getPreview', 'url' => '/core/preview.png', 'verb' => 'GET'],
+		['name' => 'RecommendedApps#index', 'url' => '/core/apps/recommended', 'verb' => 'GET'],
 		['name' => 'Svg#getSvgFromCore', 'url' => '/svg/core/{folder}/{fileName}', 'verb' => 'GET'],
 		['name' => 'Svg#getSvgFromApp', 'url' => '/svg/{app}/{fileName}', 'verb' => 'GET'],
 		['name' => 'Css#getCss', 'url' => '/css/{appName}/{fileName}', 'verb' => 'GET'],
diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e8f56c8468dfc690608a24c1e97ccf58b337418e
--- /dev/null
+++ b/core/src/components/setup/RecommendedApps.vue
@@ -0,0 +1,194 @@
+<!--
+  - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+  -
+  - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  -->
+
+<template>
+	<div class="update">
+		<h2>{{ t('core', 'Recommended apps') }}</h2>
+		<p v-if="loadingApps" class="loading">
+			{{ t('core', 'Loading apps …') }}
+		</p>
+		<p v-else-if="loadingAppsError" class="loading-error">
+			{{ t('core', 'Could not fetch list of apps from the app store.') }}
+		</p>
+		<p v-else>
+			{{ t('core', 'Installing recommended apps …') }}
+		</p>
+		<div v-for="app in recommendedApps" :key="app.id" class="app">
+			<img :src="customIcon(app.id)" :alt="t('core', 'Nextcloud app {app}', { app: app.name })">
+			<div class="info">
+				<h3>
+					{{ app.name }}
+					<span v-if="app.loading" class="icon icon-loading-small" />
+					<span v-else-if="app.active" class="icon icon-checkmark-white" />
+				</h3>
+				<p v-html="customDescription(app.id)" />
+				<p v-if="app.installationError" class="error">
+					{{ t('core', 'App download or installation failed') }}
+				</p>
+				<p v-else-if="!app.isCompatible" class="error">
+					{{ t('core', 'Can\'t install this app because it is not compatible') }}
+				</p>
+				<p v-else-if="!app.canInstall" class="error">
+					{{ t('core', 'Can\'t install this app') }}
+				</p>
+			</div>
+		</div>
+		<a :href="defaultPageUrl">{{ t('core', 'Go back') }}</a>
+	</div>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+import { generateUrl, imagePath } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import pLimit from 'p-limit'
+import { translate as t } from '@nextcloud/l10n'
+
+import logger from '../../logger'
+
+const recommended = {
+	calendar: {
+		description: t('core', 'Schedule work & meetings, synced with all your devices.'),
+		icon: imagePath('core', 'places/calendar.svg')
+	},
+	contacts: {
+		description: t('core', 'Keep your colleagues and friends in one place without leaking their private info.'),
+		icon: imagePath('core', 'places/contacts.svg')
+	},
+	mail: {
+		description: t('core', 'Simple email app nicely integrated with Files, Contacts and Calendar.'),
+		icon: imagePath('core', 'actions/mail.svg')
+	},
+	talk: {
+		description: t('core', 'Screensharing, online meetings and web conferencing – on desktop and with mobile apps.')
+	}
+}
+const recommendedIds = Object.keys(recommended)
+const defaultPageUrl = loadState('core', 'defaultPageUrl')
+
+export default {
+	name: 'RecommendedApps',
+	data() {
+		return {
+			loadingApps: true,
+			loadingAppsError: false,
+			apps: [],
+			defaultPageUrl
+		}
+	},
+	computed: {
+		recommendedApps() {
+			return this.apps.filter(app => recommendedIds.includes(app.id))
+		}
+	},
+	mounted() {
+		return axios.get(generateUrl('settings/apps/list'))
+			.then(resp => resp.data)
+			.then(data => {
+				logger.info(`${data.apps.length} apps fetched`)
+
+				this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false }))
+				logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps })
+
+				this.installApps()
+			})
+			.catch(error => {
+				logger.error('could not fetch app list', { error })
+
+				this.loadingAppsError = true
+			})
+			.then(() => {
+				this.loadingApps = false
+			})
+	},
+	methods: {
+		installApps() {
+			const limit = pLimit(1)
+			const installing = this.recommendedApps
+				.filter(app => !app.active && app.isCompatible && app.canInstall)
+				.map(app => limit(() => {
+					logger.info(`installing ${app.id}`)
+					app.loading = true
+					return axios.post(generateUrl(`settings/apps/enable`), { appIds: [app.id], groups: [] })
+						.catch(error => {
+							logger.error(`could not install ${app.id}`, { error })
+							app.installationError = true
+						})
+						.then(() => {
+							logger.info(`installed ${app.id}`)
+							app.loading = false
+						})
+				}))
+			logger.debug(`installing ${installing.length} recommended apps`)
+			Promise.all(installing)
+				.then(() => {
+					logger.info('all recommended apps installed, redirecting …')
+
+					window.location = defaultPageUrl
+				})
+				.catch(error => logger.error('could not install recommended apps', { error }))
+		},
+		customIcon(appId) {
+			if (!(appId in recommended)) {
+				logger.warn(`no app icon for recommended app ${appId}`)
+				return imagePath('core', 'places/default-app-icon.svg')
+			}
+			return recommended[appId].icon
+		},
+		customDescription(appId) {
+			if (!(appId in recommended)) {
+				logger.warn(`no app description for recommended app ${appId}`)
+				return ''
+			}
+			return recommended[appId].description
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+p.loading, p.loading-error {
+	height: 100px;
+}
+.app {
+	display: flex;
+	flex-direction: row;
+
+	img {
+		height: 64px;
+		width: 64px;
+	}
+
+	img, .info {
+		padding: 12px;
+	}
+
+	.info {
+		h3 {
+			text-align: left;
+		}
+
+		h3 > span.icon {
+			display: inline-block;
+		}
+	}
+}
+</style>
diff --git a/core/src/logger.js b/core/src/logger.js
new file mode 100644
index 0000000000000000000000000000000000000000..4a9c8623a7f815d83720c868c728d032361f0aff
--- /dev/null
+++ b/core/src/logger.js
@@ -0,0 +1,37 @@
+/*
+ * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { getLoggerBuilder } from '@nextcloud/logger'
+
+const getLogger = user => {
+	if (user === null) {
+		return getLoggerBuilder()
+			.setApp('core')
+			.build()
+	}
+	return getLoggerBuilder()
+		.setApp('core')
+		.setUid(user.uid)
+		.build()
+}
+
+export default getLogger(getCurrentUser())
diff --git a/core/src/recommendedapps.js b/core/src/recommendedapps.js
new file mode 100644
index 0000000000000000000000000000000000000000..ac0afb99daa866df67ccd7052dfb39a2184cf262
--- /dev/null
+++ b/core/src/recommendedapps.js
@@ -0,0 +1,44 @@
+/*
+ * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { getRequestToken } from '@nextcloud/auth'
+import { generateFilePath } from '@nextcloud/router'
+import { translate as t } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import logger from './logger'
+import RecommendedApps from './components/setup/RecommendedApps'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = btoa(getRequestToken())
+// eslint-disable-next-line camelcase
+__webpack_public_path__ = generateFilePath('core', '', 'js/')
+
+Vue.mixin({
+	methods: {
+		t
+	}
+})
+
+const View = Vue.extend(RecommendedApps)
+new View().$mount('#recommended-apps')
+
+logger.debug('recommended apps view rendered')
diff --git a/apps/settings/src/recommendedApps.js b/core/templates/recommendedapps.php
similarity index 88%
rename from apps/settings/src/recommendedApps.js
rename to core/templates/recommendedapps.php
index d2868b8728b3c3bca2ee73dc297a213b6186a45b..2fe13355278bdeb52235822fe125e67c005d9e77 100644
--- a/apps/settings/src/recommendedApps.js
+++ b/core/templates/recommendedapps.php
@@ -1,4 +1,6 @@
-/*
+<?php declare(strict_types=1);
+
+/**
  * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
  *
  * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
@@ -19,8 +21,8 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-export default [
-	'contacts',
-	'calendar',
-	'mail'
-]
+script('core', 'dist/recommendedapps');
+
+?>
+
+<div id="recommended-apps"></div>
diff --git a/core/webpack.js b/core/webpack.js
index 9404c354ebb4d848da2f481ac9a0c205a5e56759..cb80f049fab685f38c93c9d8faeea1d36127dfaa 100644
--- a/core/webpack.js
+++ b/core/webpack.js
@@ -7,6 +7,7 @@ module.exports = [
 			login: path.join(__dirname, 'src/login.js'),
 			main: path.join(__dirname, 'src/main.js'),
 			maintenance: path.join(__dirname, 'src/maintenance.js'),
+			recommendedapps: path.join(__dirname, 'src/recommendedapps.js'),
 		},
 		output: {
 			filename: '[name].js',
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 21e44181d971bbfffdcfe3c3289101a8d7a37bb3..016b3c360efab4260ed49b1aaf131840431791c3 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -789,6 +789,7 @@ return array(
     'OC\\Core\\Controller\\OCJSController' => $baseDir . '/core/Controller/OCJSController.php',
     'OC\\Core\\Controller\\OCSController' => $baseDir . '/core/Controller/OCSController.php',
     'OC\\Core\\Controller\\PreviewController' => $baseDir . '/core/Controller/PreviewController.php',
+    'OC\\Core\\Controller\\RecommendedAppsController' => $baseDir . '/core/Controller/RecommendedAppsController.php',
     'OC\\Core\\Controller\\SearchController' => $baseDir . '/core/Controller/SearchController.php',
     'OC\\Core\\Controller\\SetupController' => $baseDir . '/core/Controller/SetupController.php',
     'OC\\Core\\Controller\\SvgController' => $baseDir . '/core/Controller/SvgController.php',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index 6e4762360e078289c451e9c8cce4cdfb2c4d0a3b..5d63c7740eb7066e100406a238d5aa2eb085bf0e 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -818,6 +818,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
         'OC\\Core\\Controller\\OCJSController' => __DIR__ . '/../../..' . '/core/Controller/OCJSController.php',
         'OC\\Core\\Controller\\OCSController' => __DIR__ . '/../../..' . '/core/Controller/OCSController.php',
         'OC\\Core\\Controller\\PreviewController' => __DIR__ . '/../../..' . '/core/Controller/PreviewController.php',
+        'OC\\Core\\Controller\\RecommendedAppsController' => __DIR__ . '/../../..' . '/core/Controller/RecommendedAppsController.php',
         'OC\\Core\\Controller\\SearchController' => __DIR__ . '/../../..' . '/core/Controller/SearchController.php',
         'OC\\Core\\Controller\\SetupController' => __DIR__ . '/../../..' . '/core/Controller/SetupController.php',
         'OC\\Core\\Controller\\SvgController' => __DIR__ . '/../../..' . '/core/Controller/SvgController.php',
diff --git a/package-lock.json b/package-lock.json
index 0da04927c39593dad801f29f29232b444d32f661..99e65c93f373bd267215090cad729b3d80bac156 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2168,6 +2168,21 @@
         }
       }
     },
+    "@nextcloud/l10n": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-0.2.1.tgz",
+      "integrity": "sha512-iLdyxluCehsRibR4R/nH3O8T9CcGoAaW3eWEdQW2qPtn6eEiBXASek5nWhXa5hko1GvE7koYia4FoTWuL85/Ng==",
+      "requires": {
+        "core-js": "3.2.1"
+      },
+      "dependencies": {
+        "core-js": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.2.1.tgz",
+          "integrity": "sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw=="
+        }
+      }
+    },
     "@nextcloud/logger": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/@nextcloud/logger/-/logger-0.1.0.tgz",
@@ -3122,7 +3137,7 @@
     },
     "browserify-aes": {
       "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
+      "resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
       "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
       "dev": true,
       "requires": {
@@ -3159,7 +3174,7 @@
     },
     "browserify-rsa": {
       "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
+      "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
       "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
       "dev": true,
       "requires": {
@@ -3203,7 +3218,7 @@
     },
     "buffer": {
       "version": "4.9.1",
-      "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
+      "resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
       "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=",
       "dev": true,
       "requires": {
@@ -3644,7 +3659,7 @@
     },
     "create-hash": {
       "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+      "resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
       "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
       "dev": true,
       "requires": {
@@ -3657,7 +3672,7 @@
     },
     "create-hmac": {
       "version": "1.1.7",
-      "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
+      "resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
       "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
       "dev": true,
       "requires": {
@@ -3918,7 +3933,7 @@
     },
     "diffie-hellman": {
       "version": "5.0.3",
-      "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+      "resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
       "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
       "dev": true,
       "requires": {
@@ -4514,7 +4529,7 @@
     },
     "events": {
       "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz",
+      "resolved": "http://registry.npmjs.org/events/-/events-3.0.0.tgz",
       "integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==",
       "dev": true
     },
@@ -5551,7 +5566,7 @@
     },
     "get-stream": {
       "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+      "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
       "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
       "dev": true,
       "requires": {
@@ -8420,7 +8435,7 @@
     },
     "safe-regex": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+      "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
       "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
       "requires": {
         "ret": "~0.1.10"
@@ -8748,7 +8763,7 @@
     },
     "sha.js": {
       "version": "2.4.11",
-      "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+      "resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
       "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
       "dev": true,
       "requires": {
@@ -9062,7 +9077,7 @@
     },
     "stream-browserify": {
       "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
+      "resolved": "http://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
       "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==",
       "dev": true,
       "requires": {
@@ -9162,7 +9177,7 @@
     },
     "strip-eof": {
       "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+      "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
       "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=",
       "dev": true
     },
@@ -9408,7 +9423,7 @@
     },
     "tty-browserify": {
       "version": "0.0.0",
-      "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
+      "resolved": "http://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
       "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=",
       "dev": true
     },
@@ -9462,7 +9477,7 @@
     },
     "underscore": {
       "version": "1.9.1",
-      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz",
+      "resolved": "http://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz",
       "integrity": "sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg=="
     },
     "unicode-canonical-property-names-ecmascript": {
diff --git a/package.json b/package.json
index dad7ead15c42380d99840158a65197bb86681ef1..8a9b154b476b44df43b388e3713017564abb886a 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
     "@nextcloud/dialogs": "^0.1.1",
     "@nextcloud/event-bus": "^0.2.1",
     "@nextcloud/initial-state": "^0.2.0",
+    "@nextcloud/l10n": "^0.2.1",
     "@nextcloud/logger": "^0.1.0",
     "@nextcloud/paths": "^0.2.0",
     "@nextcloud/router": "^0.1.0",