diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php
index 69d2790df1abd2df5f66294113f7e8acc8f4f6aa..08a324a46ec726cd40e75b2d3599eb6d1d67b102 100644
--- a/apps/files/lib/Controller/TemplateController.php
+++ b/apps/files/lib/Controller/TemplateController.php
@@ -1,5 +1,5 @@
 <?php
-/*
+/**
  * @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
  *
  * @author Julius Härtl <jus@bitgrid.net>
diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php
index 124363f07bba9708bd5f5de457cf32ba08dc1a72..846ec14c4aa68403ca114ba00aa1825a78cd273a 100644
--- a/apps/files/lib/Controller/ViewController.php
+++ b/apps/files/lib/Controller/ViewController.php
@@ -190,6 +190,7 @@ class ViewController extends Controller {
 		// Load the files we need
 		\OCP\Util::addStyle('files', 'merged');
 		\OCP\Util::addScript('files', 'merged-index');
+		\OCP\Util::addScript('files', 'dist/templates');
 
 		// mostly for the home storage's free space
 		// FIXME: Make non static
diff --git a/apps/files/lib/Event/LoadAdditionalScriptsEvent.php b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php
index de222ccc55a8d6283a1efeb14d1cdaf0f86b749c..e5943c6f895f2e03317208173f0dc9b667707f1b 100644
--- a/apps/files/lib/Event/LoadAdditionalScriptsEvent.php
+++ b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php
@@ -30,7 +30,8 @@ namespace OCA\Files\Event;
 use OCP\EventDispatcher\Event;
 
 /**
- * This event is triggered when the files app is rendered. It canb e used to add additional scripts to the files app.
+ * This event is triggered when the files app is rendered.
+ * It can be used to add additional scripts to the files app.
  *
  * @since 17.0.0
  */
diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue
new file mode 100644
index 0000000000000000000000000000000000000000..538e1bcff7ba96d4efd5a64fd38793680867a76e
--- /dev/null
+++ b/apps/files/src/components/TemplatePreview.vue
@@ -0,0 +1,203 @@
+<!--
+  - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @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>
+	<li class="template-picker__item">
+		<input :id="id"
+			:checked="checked"
+			type="radio"
+			class="radio"
+			name="template-picker"
+			@change="onCheck">
+
+		<label :for="id" class="template-picker__label">
+			<div class="template-picker__preview">
+				<img class="template-picker__image"
+					:class="failedPreview ? 'template-picker__image--failed' : ''"
+					:src="realPreviewUrl"
+					alt=""
+					draggable="false"
+					@error="onFailure">
+			</div>
+
+			<span class="template-picker__title">
+				{{ basename }}
+			</span>
+		</label>
+	</li>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import { encodeFilePath } from '../utils/fileUtils'
+import { getToken, isPublic } from '../utils/davUtils'
+
+// preview width generation
+const previewWidth = 256
+
+export default {
+	name: 'TemplatePreview',
+	inheritAttrs: false,
+
+	props: {
+		basename: {
+			type: String,
+			required: true,
+		},
+		checked: {
+			type: Boolean,
+			default: false,
+		},
+		fileid: {
+			type: [String, Number],
+			required: true,
+		},
+		filename: {
+			type: String,
+			required: true,
+		},
+		previewUrl: {
+			type: String,
+			default: null,
+		},
+		hasPreview: {
+			type: Boolean,
+			default: true,
+		},
+		mime: {
+			type: String,
+			required: true,
+		},
+		ratio: {
+			type: Number,
+			default: null,
+		},
+	},
+
+	data() {
+		return {
+			failedPreview: false,
+		}
+	},
+
+	computed: {
+		id() {
+			return `template-picker-${this.fileid}`
+		},
+
+		realPreviewUrl() {
+			// If original preview failed, fallback to mime icon
+			if (this.failedPreview && this.mimeIcon) {
+				return generateUrl(this.mimeIcon)
+			}
+
+			if (this.previewUrl) {
+				return this.previewUrl
+			}
+			// TODO: find a nicer standard way of doing this?
+			if (isPublic()) {
+				return generateUrl(`/apps/files_sharing/publicpreview/${getToken()}?fileId=${this.fileid}&file=${encodeFilePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`)
+			}
+			return generateUrl(`/core/preview?fileId=${this.fileid}&x=${previewWidth}&y=${previewWidth}&a=1`)
+		},
+
+		mimeIcon() {
+			return OC.MimeType.getIconUrl(this.mime)
+		},
+	},
+
+	methods: {
+		onCheck() {
+			this.$emit('check', this.fileid)
+		},
+		onFailure() {
+			this.failedPreview = true
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+
+.template-picker {
+	&__item {
+		display: flex;
+	}
+
+	&__label {
+		display: flex;
+		// Align in the middle of the grid
+		align-items: center;
+		flex: 1 1;
+		flex-direction: column;
+		margin: var(--margin);
+
+		&, * {
+			cursor: pointer;
+			user-select: none;
+		}
+
+		&::before {
+			display: none !important;
+		}
+	}
+
+	&__preview {
+		display: flex;
+		overflow: hidden;
+		// Stretch so all entries are the same width
+		flex: 1 1;
+		width: var(--width);
+		min-height: var(--width);
+		max-height: var(--height);
+		padding: var(--margin);
+		border: var(--border) solid var(--color-border);
+		border-radius: var(--border-radius-large);
+
+		input:checked + label > & {
+			border-color: var(--color-primary);
+		}
+	}
+
+	&__image {
+		max-width: 100%;
+		background-color: var(--color-main-background);
+
+		&--failed {
+			width: calc(var(--margin) * 8);
+			// Center mime icon
+			margin: auto;
+			background-color: transparent !important;
+		}
+	}
+
+	&__title {
+		overflow: hidden;
+		// also count preview border
+		max-width: calc(var(--width) + 2*2px);
+		padding: var(--margin);
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+}
+
+</style>
diff --git a/apps/files/src/templates.js b/apps/files/src/templates.js
new file mode 100644
index 0000000000000000000000000000000000000000..3cffc30085c7def3f5e1bf82586a18454a27145b
--- /dev/null
+++ b/apps/files/src/templates.js
@@ -0,0 +1,92 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @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 { getLoggerBuilder } from '@nextcloud/logger'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import TemplatePickerView from './views/TemplatePicker'
+
+// Set up logger
+const logger = getLoggerBuilder()
+	.setApp('files')
+	.detectUser()
+	.build()
+
+// Add translates functions
+Vue.mixin({
+	methods: {
+		t,
+		n,
+	},
+})
+
+// Create document root
+const TemplatePickerRoot = document.createElement('div')
+TemplatePickerRoot.id = 'template-picker'
+document.body.appendChild(TemplatePickerRoot)
+
+// Retrieve and init templates
+const templates = loadState('files', 'templates', [])
+logger.debug('Templates providers', templates)
+
+// Init vue app
+const View = Vue.extend(TemplatePickerView)
+const TemplatePicker = new View({
+	name: 'TemplatePicker',
+	propsData: {
+		logger,
+	},
+})
+TemplatePicker.$mount('#template-picker')
+
+// Init template engine after load
+window.addEventListener('DOMContentLoaded', function() {
+	// Init template files menu
+	templates.forEach((provider, index) => {
+
+		const newTemplatePlugin = {
+			attach(menu) {
+				const fileList = menu.fileList
+
+				// only attach to main file list, public view is not supported yet
+				if (fileList.id !== 'files' && fileList.id !== 'files.public') {
+					return
+				}
+
+				// register the new menu entry
+				menu.addMenuEntry({
+					id: `template-new-${provider.app}-${index}`,
+					displayName: provider.label,
+					templateName: provider.label + provider.extension,
+					iconClass: provider.iconClass || 'icon-file',
+					fileType: 'file',
+					actionHandler(name) {
+						TemplatePicker.open(name, provider)
+					},
+				})
+			},
+		}
+		OC.Plugins.register('OCA.Files.NewFileMenu', newTemplatePlugin)
+	})
+})
diff --git a/apps/files/src/utils/davUtils.js b/apps/files/src/utils/davUtils.js
new file mode 100644
index 0000000000000000000000000000000000000000..f64801f08dd6c2453e5c77438f04c8ee8b1d451a
--- /dev/null
+++ b/apps/files/src/utils/davUtils.js
@@ -0,0 +1,42 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @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 { generateRemoteUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+
+const getRootPath = function() {
+	if (getCurrentUser()) {
+		return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`)
+	} else {
+		return generateRemoteUrl('webdav').replace('/remote.php', '/public.php')
+	}
+}
+
+const isPublic = function() {
+	return !getCurrentUser()
+}
+
+const getToken = function() {
+	return document.getElementById('sharingToken') && document.getElementById('sharingToken').value
+}
+
+export { getRootPath, getToken, isPublic }
diff --git a/apps/files/src/utils/fileUtils.js b/apps/files/src/utils/fileUtils.js
new file mode 100644
index 0000000000000000000000000000000000000000..97d1c333566f241875231a10c67d731b10e60c6a
--- /dev/null
+++ b/apps/files/src/utils/fileUtils.js
@@ -0,0 +1,53 @@
+/**
+ * @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @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/>.
+ *
+ */
+
+/**
+ * Get an url encoded path
+ *
+ * @param {String} path the full path
+ * @returns {string} url encoded file path
+ */
+const encodeFilePath = function(path) {
+	const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
+	let relativePath = ''
+	pathSections.forEach((section) => {
+		if (section !== '') {
+			relativePath += '/' + encodeURIComponent(section)
+		}
+	})
+	return relativePath
+}
+
+/**
+ * Extract dir and name from file path
+ *
+ * @param {String} path the full path
+ * @returns {String[]} [dirPath, fileName]
+ */
+const extractFilePaths = function(path) {
+	const pathSections = path.split('/')
+	const fileName = pathSections[pathSections.length - 1]
+	const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
+	return [dirPath, fileName]
+}
+
+export { encodeFilePath, extractFilePaths }
diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue
new file mode 100644
index 0000000000000000000000000000000000000000..84c7c38aba47bfcf90d200ea914139b184c79877
--- /dev/null
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -0,0 +1,268 @@
+<!--
+  - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @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>
+	<Modal v-if="opened"
+		:clear-view-delay="-1"
+		class="templates-picker"
+		size="large"
+		@close="close">
+		<form class="templates-picker__form"
+			:style="style"
+			@submit.prevent.stop="onSubmit">
+			<h3>{{ t('files', 'Pick a template') }}</h3>
+
+			<!-- Templates list -->
+			<ul class="templates-picker__list">
+				<TemplatePreview
+					v-bind="emptyTemplate"
+					:checked="checked === emptyTemplate.fileid"
+					@check="onCheck" />
+
+				<TemplatePreview
+					v-for="template in provider.templates"
+					:key="template.fileid"
+					v-bind="template"
+					:checked="checked === template.fileid"
+					:ratio="provider.ratio"
+					@check="onCheck" />
+			</ul>
+
+			<!-- Cancel and submit -->
+			<div class="templates-picker__buttons">
+				<button @click="close">
+					{{ t('files', 'Cancel') }}
+				</button>
+				<input type="submit"
+					class="primary"
+					:value="t('files', 'Create')"
+					:aria-label="t('files', 'Create a new file with the ')">
+			</div>
+		</form>
+
+		<EmptyContent class="templates-picker__loading" v-if="loading" icon="icon-loading">
+			{{ t('files', 'Creating file') }}
+		</EmptyContent>
+	</Modal>
+</template>
+
+<script>
+import { generateOcsUrl } from '@nextcloud/router'
+import { showError } from '@nextcloud/dialogs'
+
+import axios from '@nextcloud/axios'
+import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
+import Modal from '@nextcloud/vue/dist/Components/Modal'
+
+import TemplatePreview from '../components/TemplatePreview'
+
+const border = 2
+const margin = 8
+const width = margin * 20
+
+export default {
+	name: 'TemplatePicker',
+
+	components: {
+		EmptyContent,
+		Modal,
+		TemplatePreview,
+	},
+
+	props: {
+		logger: {
+			type: Object,
+			required: true,
+		},
+	},
+
+	data() {
+		return {
+			// Check empty template by default
+			checked: -1,
+			loading: false,
+			name: null,
+			opened: false,
+			provider: null,
+		}
+	},
+
+	computed: {
+		emptyTemplate() {
+			return {
+				basename: t('files', 'Blank'),
+				fileid: -1,
+				filename: this.t('files', 'Blank'),
+				hasPreview: false,
+				mime: this.provider?.mimetypes[0] || this.provider?.mimetypes,
+			}
+		},
+
+		selectedTemplate() {
+			return this.provider.templates.find(template => template.fileid === this.checked)
+		},
+
+		/**
+		 * Style css vars bin,d
+		 * @returns {Object}
+		 */
+		style() {
+			return {
+				'--margin': margin + 'px',
+				'--width': width + 'px',
+				'--border': border + 'px',
+				'--fullwidth': width + 2 * margin + 2 * border + 'px',
+				'--height': this.ratio ? width * this.ratio + 'px' : null,
+			}
+		},
+	},
+
+	methods: {
+		/**
+		 * Open the picker
+		 * @param {string} name the file name to create
+		 * @param {object} provider the template provider picked
+		 */
+		open(name, provider) {
+			this.checked = this.emptyTemplate.fileid
+			this.name = name
+			this.opened = true
+			this.provider = provider
+		},
+
+		/**
+		 * Close the picker and reset variables
+		 */
+		close() {
+			this.checked = this.emptyTemplate.fileid
+			this.loading = false
+			this.name = null
+			this.opened = false
+			this.provider = null
+		},
+
+		/**
+		 * Manages the radio template picker change
+		 * @param {number} fileid the selected template file id
+		 */
+		onCheck(fileid) {
+			this.checked = fileid
+		},
+
+		async onSubmit() {
+			this.loading = true
+			const currentDirectory = this.getCurrentDirectory()
+			const fileList = OCA?.Files?.App?.currentFileList
+
+			try {
+				const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates', 2) + 'create', {
+					filePath: `${currentDirectory}/${this.name}`,
+					templatePath: this.selectedTemplate?.filename,
+					templateType: this.selectedTemplate?.templateType,
+				})
+
+				const fileInfo = response.data.ocs.data
+				this.logger.debug('Created new file', fileInfo)
+
+				// Run default action
+				const fileAction = OCA.Files.fileActions.getDefaultFileAction(fileInfo.mime, 'file', OC.PERMISSION_ALL)
+				fileAction.action(fileInfo.basename, {
+					$file: null,
+					dir: currentDirectory,
+					fileList,
+					fileActions: fileList?.fileActions,
+				})
+
+				// Reload files list
+				fileList?.reload?.() || window.location.reload()
+
+				this.close()
+			} catch (error) {
+				this.logger.error('Error while creating the new file from template', error)
+				showError(this.t('files', 'Unable to create new file from template'))
+			} finally {
+				this.loading = false
+			}
+		},
+
+		/**
+		 * Return the current directory, fallback to root
+		 * @returns {string}
+		 */
+		getCurrentDirectory() {
+			const currentDirInfo = OCA?.Files?.App?.currentFileList?.dirInfo
+				|| { path: '/', name: '' }
+
+			// Make sure we don't have double slashes
+			return `${currentDirInfo.path}/${currentDirInfo.name}`.replace(/\/\//gi, '/')
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.templates-picker {
+	&__form {
+		padding: calc(var(--margin) * 2);
+		// Will be handled by the buttons
+		padding-bottom: 0;
+	}
+
+	&__list {
+		display: grid;
+		grid-gap: calc(var(--margin) * 2);
+		grid-auto-columns: 1fr;
+		// We want maximum 5 columns. Putting 6 as we don't count the grid gap. So it will always be lower than 6
+		max-width: calc(var(--fullwidth) * 6);
+		grid-template-columns: repeat(auto-fit, minmax(var(--fullwidth), 1fr));
+		// Make sure all rows are the same height
+		grid-auto-rows: 1fr;
+	}
+	&__buttons {
+		display: flex;
+		justify-content: space-between;
+		padding: calc(var(--margin) * 2) var(--margin);
+		position: sticky;
+		// Make sure the templates list doesn't weirdly peak under when scrolled. Happens on some rare occasions
+		bottom: -1px;
+		background-color: var(--color-main-background);
+	}
+
+	// Make sure we're relative for the loading emptycontent on top
+	/deep/ .modal-container {
+		position: relative;
+		overflow-y: auto !important;
+	}
+
+	&__loading {
+		position: absolute;
+		top: 0;
+		left: 0;
+		justify-content: center;
+		width: 100%;
+		height: 100%;
+		margin: 0;
+		background-color: var(--color-main-background-translucent);
+	}
+}
+
+</style>
diff --git a/apps/files/webpack.js b/apps/files/webpack.js
index 0c16e476533f2f70d2faaebd94d3473dbbf34734..8dbbad5081f9a96d374f9f6fb6be62cc1e6da7be 100644
--- a/apps/files/webpack.js
+++ b/apps/files/webpack.js
@@ -2,7 +2,8 @@ const path = require('path');
 
 module.exports = {
 	entry: {
-		'sidebar': path.join(__dirname, 'src', 'sidebar.js'),
+		sidebar: path.join(__dirname, 'src', 'sidebar.js'),
+		templates: path.join(__dirname, 'src', 'templates.js'),
 		'files-app-settings': path.join(__dirname, 'src', 'files-app-settings.js'),
 		'personal-settings': path.join(__dirname, 'src', 'main-personal-settings.js'),
 	},
diff --git a/lib/private/Files/Template/TemplateManager.php b/lib/private/Files/Template/TemplateManager.php
index 8b0c78d0b9aa18d0ddbcf38b8ff6231b8fc1f4fa..3b033a440480d487b821ad83c4cef2e5e99175de 100644
--- a/lib/private/Files/Template/TemplateManager.php
+++ b/lib/private/Files/Template/TemplateManager.php
@@ -1,5 +1,5 @@
 <?php
-/*
+/**
  * @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
  *
  * @author Julius Härtl <jus@bitgrid.net>
diff --git a/lib/public/Files/Template/ITemplateManager.php b/lib/public/Files/Template/ITemplateManager.php
index 61dbb68cd773d8b3f137fdfd37f87107da09e439..94545c17b417aabdd4d6a8fb1a01f08e789d46e5 100644
--- a/lib/public/Files/Template/ITemplateManager.php
+++ b/lib/public/Files/Template/ITemplateManager.php
@@ -1,5 +1,5 @@
 <?php
-/*
+/**
  * @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
  *
  * @author Julius Härtl <jus@bitgrid.net>