diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl
index 1aabfa2f5c..9bf16f8aa5 100644
--- a/templates/base/footer.tmpl
+++ b/templates/base/footer.tmpl
@@ -22,7 +22,7 @@
 		<script src='https://hcaptcha.com/1/api.js' async></script>
 	{{end}}
 {{end}}
-	<script src="{{AssetUrlPrefix}}/js/index.js?v={{MD5 AppVer}}"></script>
+	<script src="{{AssetUrlPrefix}}/js/index.js?v={{MD5 AppVer}}" onerror="alert('Failed to load asset files from ' + this.src + ', please make sure the asset files can be accessed and the ROOT_URL setting in app.ini is correct.')"></script>
 {{template "custom/footer" .}}
 </body>
 </html>
diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl
index 2a9c24255d..35157e9b95 100644
--- a/templates/base/head.tmpl
+++ b/templates/base/head.tmpl
@@ -19,51 +19,12 @@
 	<link rel="alternate" type="application/atom+xml" title="" href="{{.FeedURL}}.atom">
 	<link rel="alternate" type="application/rss+xml" title="" href="{{.FeedURL}}.rss">
 {{end}}
-	<script>
-		<!-- /* eslint-disable */ -->
-		window.config = {
-			appVer: '{{AppVer}}',
-			appSubUrl: '{{AppSubUrl}}',
-			assetUrlPrefix: '{{AssetUrlPrefix}}',
-			runModeIsProd: {{.RunModeIsProd}},
-			customEmojis: {{CustomEmojis}},
-			useServiceWorker: {{UseServiceWorker}},
-			csrfToken: '{{.CsrfToken}}',
-			pageData: {{.PageData}},
-			requireTribute: {{.RequireTribute}},
-			notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
-			enableTimeTracking: {{EnableTimetracking}},
-			{{if .RequireTribute}}
-			tributeValues: Array.from(new Map([
-				{{ range .Participants }}
-				['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}',
-				name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink}}'}],
-				{{ end }}
-				{{ range .Assignees }}
-				['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}',
-				name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink}}'}],
-				{{ end }}
-				{{ range .MentionableTeams }}
-					['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}',
-					name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
-				{{ end }}
-			]).values()),
-			{{end}}
-			mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
-			{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
-			i18n: {
-				copy_success: '{{.i18n.Tr "copy_success"}}',
-				copy_error: '{{.i18n.Tr "copy_error"}}',
-				error_occurred: '{{.i18n.Tr "error.occurred"}}',
-				network_error: '{{.i18n.Tr "error.network_error"}}',
-			},
-		};
-		{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
-		window.config.pageData = window.config.pageData || {};
-	</script>
 	<link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml">
 	<link rel="alternate icon" href="{{AssetUrlPrefix}}/img/favicon.png" type="image/png">
 	<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/index.css?v={{MD5 AppVer}}">
+
+	{{template "base/head_script" .}}
+
 	<noscript>
 		<style>
 			.dropdown:hover > .menu { display: block; }
diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl
new file mode 100644
index 0000000000..e6a8060a16
--- /dev/null
+++ b/templates/base/head_script.tmpl
@@ -0,0 +1,49 @@
+{{/*
+==== DO NOT EDIT ====
+If you are customizing Gitea, please do not change this file.
+If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
+*/}}
+<script>
+	<!-- /* eslint-disable */ -->
+	window.addEventListener('error', function(e) {window._globalHandlerErrors=window._globalHandlerErrors||[]; window._globalHandlerErrors.push(e);});
+	window.config = {
+		appVer: '{{AppVer}}',
+		appUrl: '{{AppUrl}}',
+		appSubUrl: '{{AppSubUrl}}',
+		assetUrlPrefix: '{{AssetUrlPrefix}}',
+		runModeIsProd: {{.RunModeIsProd}},
+		customEmojis: {{CustomEmojis}},
+		useServiceWorker: {{UseServiceWorker}},
+		csrfToken: '{{.CsrfToken}}',
+		pageData: {{.PageData}},
+		requireTribute: {{.RequireTribute}},
+		notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
+		enableTimeTracking: {{EnableTimetracking}},
+		{{if .RequireTribute}}
+		tributeValues: Array.from(new Map([
+			{{ range .Participants }}
+			['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}',
+			name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink}}'}],
+			{{ end }}
+			{{ range .Assignees }}
+			['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}',
+			name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink}}'}],
+			{{ end }}
+			{{ range .MentionableTeams }}
+				['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}',
+				name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
+			{{ end }}
+		]).values()),
+		{{end}}
+		mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
+		{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
+		i18n: {
+			copy_success: '{{.i18n.Tr "copy_success"}}',
+			copy_error: '{{.i18n.Tr "copy_error"}}',
+			error_occurred: '{{.i18n.Tr "error.occurred"}}',
+			network_error: '{{.i18n.Tr "error.network_error"}}',
+		},
+	};
+	{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
+	window.config.pageData = window.config.pageData || {};
+</script>
diff --git a/web_src/js/bootstrap.js b/web_src/js/bootstrap.js
new file mode 100644
index 0000000000..cf13b2a559
--- /dev/null
+++ b/web_src/js/bootstrap.js
@@ -0,0 +1,41 @@
+import {joinPaths} from './utils.js';
+
+// DO NOT IMPORT window.config HERE!
+// to make sure the error handler always works, we should never import `window.config`, because some user's custom template breaks it.
+
+// This sets up the URL prefix used in webpack's chunk loading.
+// This file must be imported before any lazy-loading is being attempted.
+__webpack_public_path__ = joinPaths(window?.config?.assetUrlPrefix ?? '/', '/');
+
+export function showGlobalErrorMessage(msg) {
+  const pageContent = document.querySelector('.page-content');
+  if (!pageContent) return;
+  const el = document.createElement('div');
+  el.innerHTML = `<div class="ui container negative message center aligned js-global-error" style="white-space: pre-line;"></div>`;
+  el.childNodes[0].textContent = msg;
+  pageContent.prepend(el.childNodes[0]);
+}
+
+/**
+ * @param {ErrorEvent} e
+ */
+function processWindowErrorEvent(e) {
+  showGlobalErrorMessage(`JavaScript error: ${e.message} (${e.filename} @ ${e.lineno}:${e.colno}). Open browser console to see more details.`);
+}
+
+function initGlobalErrorHandler() {
+  if (!window.config) {
+    showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
+  }
+
+  // we added an event handler for window error at the very beginning of <script> of page head
+  // the handler calls `_globalHandlerErrors.push` (array method) to record all errors occur before this init
+  // then in this init, we can collect all error events and show them
+  for (const e of window._globalHandlerErrors || []) {
+    processWindowErrorEvent(e);
+  }
+  // then, change _globalHandlerErrors to an object with push method, to process further error events directly
+  window._globalHandlerErrors = {'push': (e) => processWindowErrorEvent(e)};
+}
+
+initGlobalErrorHandler();
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index a9baf9be0c..60af4d0d67 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -3,8 +3,9 @@ import 'jquery.are-you-sure';
 import {mqBinarySearch} from '../utils.js';
 import createDropzone from './dropzone.js';
 import {initCompColorPicker} from './comp/ColorPicker.js';
+import {showGlobalErrorMessage} from '../bootstrap.js';
 
-const {csrfToken} = window.config;
+const {appUrl, csrfToken} = window.config;
 
 export function initGlobalFormDirtyLeaveConfirm() {
   // Warn users that try to leave a page after entering data into a form.
@@ -343,3 +344,20 @@ export function initGlobalButtons() {
     });
   });
 }
+
+/**
+ * Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
+ *   * Cross-origin API request without correct cookie
+ *   * Incorrect href in <a>
+ *   * ...
+ * So we check whether current URL starts with AppUrl(ROOT_URL).
+ * If they don't match, show a warning to users.
+ */
+export function checkAppUrl() {
+  const curUrl = window.location.href;
+  if (curUrl.startsWith(appUrl)) {
+    return;
+  }
+  showGlobalErrorMessage(`Your ROOT_URL in app.ini is ${appUrl} but you are visiting ${curUrl}
+You should set ROOT_URL correctly, otherwise the web may not work correctly.`);
+}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index b7eba5e664..18b949e4e6 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -1,4 +1,5 @@
-import './publicpath.js';
+// bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors
+import './bootstrap.js';
 
 import $ from 'jquery';
 import {initVueEnv} from './components/VueComponentLoader.js';
@@ -39,6 +40,7 @@ import {
 } from './features/repo-issue.js';
 import {initRepoEllipsisButton, initRepoCommitLastCommitLoader} from './features/repo-commit.js';
 import {
+  checkAppUrl,
   initFootLanguageMenu,
   initGlobalButtonClickOnEnter,
   initGlobalButtons,
@@ -82,7 +84,6 @@ $.fn.tab.settings.silent = true;
 $.fn.checkbox.settings.enableEnterKey = false;
 
 initVueEnv();
-
 $(document).ready(() => {
   initGlobalCommon();
 
@@ -169,4 +170,6 @@ $(document).ready(() => {
   initUserAuthWebAuthn();
   initUserAuthWebAuthnRegister();
   initUserSettings();
+
+  checkAppUrl();
 });
diff --git a/web_src/js/publicpath.js b/web_src/js/publicpath.js
deleted file mode 100644
index 44448a8447..0000000000
--- a/web_src/js/publicpath.js
+++ /dev/null
@@ -1,6 +0,0 @@
-// This sets up the URL prefix used in webpack's chunk loading.
-// This file must be imported before any lazy-loading is being attempted.
-import {joinPaths} from './utils.js';
-const {assetUrlPrefix} = window.config;
-
-__webpack_public_path__ = joinPaths(assetUrlPrefix, '/');