diff --git a/cmd/embedded.go b/cmd/embedded.go
index 30fc7103d838..ffdc3d6a6364 100644
--- a/cmd/embedded.go
+++ b/cmd/embedded.go
@@ -123,7 +123,7 @@ func initEmbeddedExtractor(c *cli.Context) error {
 
 	sections["public"] = &section{Path: "public", Names: public.AssetNames, IsDir: public.AssetIsDir, Asset: public.Asset}
 	sections["options"] = &section{Path: "options", Names: options.AssetNames, IsDir: options.AssetIsDir, Asset: options.Asset}
-	sections["templates"] = &section{Path: "templates", Names: templates.AssetNames, IsDir: templates.AssetIsDir, Asset: templates.Asset}
+	sections["templates"] = &section{Path: "templates", Names: templates.BuiltinAssetNames, IsDir: templates.BuiltinAssetIsDir, Asset: templates.BuiltinAsset}
 
 	for _, sec := range sections {
 		assets = append(assets, buildAssetList(sec, pats, c)...)
diff --git a/cmd/web.go b/cmd/web.go
index 43f106f780b0..e09560bb86cf 100644
--- a/cmd/web.go
+++ b/cmd/web.go
@@ -126,8 +126,10 @@ func runWeb(ctx *cli.Context) error {
 				return err
 			}
 		}
-		c := install.Routes()
+		installCtx, cancel := context.WithCancel(graceful.GetManager().HammerContext())
+		c := install.Routes(installCtx)
 		err := listen(c, false)
+		cancel()
 		if err != nil {
 			log.Critical("Unable to open listener for installer. Is Gitea already running?")
 			graceful.GetManager().DoGracefulShutdown()
@@ -175,7 +177,7 @@ func runWeb(ctx *cli.Context) error {
 	}
 
 	// Set up Chi routes
-	c := routers.NormalRoutes()
+	c := routers.NormalRoutes(graceful.GetManager().HammerContext())
 	err := listen(c, true)
 	<-graceful.GetManager().Done()
 	log.Info("PID: %d Gitea Web Finished", os.Getpid())
diff --git a/contrib/pr/checkout.go b/contrib/pr/checkout.go
index 65762a91e3b8..900b44d16769 100644
--- a/contrib/pr/checkout.go
+++ b/contrib/pr/checkout.go
@@ -27,6 +27,7 @@ import (
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/unittest"
 	gitea_git "code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/markup"
 	"code.gitea.io/gitea/modules/markup/external"
 	repo_module "code.gitea.io/gitea/modules/repository"
@@ -117,7 +118,7 @@ func runPR() {
 	// routers.GlobalInit()
 	external.RegisterRenderers()
 	markup.Init()
-	c := routers.NormalRoutes()
+	c := routers.NormalRoutes(graceful.GetManager().HammerContext())
 
 	log.Printf("[PR] Ready for testing !\n")
 	log.Printf("[PR] Login with user1, user2, user3, ... with pass: password\n")
diff --git a/go.mod b/go.mod
index fa6fb911db1a..d578e145ccef 100644
--- a/go.mod
+++ b/go.mod
@@ -28,6 +28,7 @@ require (
 	github.com/emirpasic/gods v1.18.1
 	github.com/ethantkoenig/rupture v1.0.1
 	github.com/felixge/fgprof v0.9.2
+	github.com/fsnotify/fsnotify v1.5.4
 	github.com/gliderlabs/ssh v0.3.4
 	github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b
 	github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d
@@ -161,7 +162,6 @@ require (
 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
 	github.com/felixge/httpsnoop v1.0.2 // indirect
 	github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect
-	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/fullstorydev/grpcurl v1.8.1 // indirect
 	github.com/fxamacker/cbor/v2 v2.4.0 // indirect
 	github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f // indirect
diff --git a/integrations/api_activitypub_person_test.go b/integrations/api_activitypub_person_test.go
index e19da40864e9..c0548df0bcf4 100644
--- a/integrations/api_activitypub_person_test.go
+++ b/integrations/api_activitypub_person_test.go
@@ -23,10 +23,10 @@ import (
 
 func TestActivityPubPerson(t *testing.T) {
 	setting.Federation.Enabled = true
-	c = routers.NormalRoutes()
+	c = routers.NormalRoutes(context.TODO())
 	defer func() {
 		setting.Federation.Enabled = false
-		c = routers.NormalRoutes()
+		c = routers.NormalRoutes(context.TODO())
 	}()
 
 	onGiteaRun(t, func(*testing.T, *url.URL) {
@@ -60,10 +60,10 @@ func TestActivityPubPerson(t *testing.T) {
 
 func TestActivityPubMissingPerson(t *testing.T) {
 	setting.Federation.Enabled = true
-	c = routers.NormalRoutes()
+	c = routers.NormalRoutes(context.TODO())
 	defer func() {
 		setting.Federation.Enabled = false
-		c = routers.NormalRoutes()
+		c = routers.NormalRoutes(context.TODO())
 	}()
 
 	onGiteaRun(t, func(*testing.T, *url.URL) {
@@ -75,10 +75,10 @@ func TestActivityPubMissingPerson(t *testing.T) {
 
 func TestActivityPubPersonInbox(t *testing.T) {
 	setting.Federation.Enabled = true
-	c = routers.NormalRoutes()
+	c = routers.NormalRoutes(context.TODO())
 	defer func() {
 		setting.Federation.Enabled = false
-		c = routers.NormalRoutes()
+		c = routers.NormalRoutes(context.TODO())
 	}()
 
 	srv := httptest.NewServer(c)
diff --git a/integrations/api_nodeinfo_test.go b/integrations/api_nodeinfo_test.go
index cf9ff4da1b53..bbb79120784e 100644
--- a/integrations/api_nodeinfo_test.go
+++ b/integrations/api_nodeinfo_test.go
@@ -5,6 +5,7 @@
 package integrations
 
 import (
+	"context"
 	"net/http"
 	"net/url"
 	"testing"
@@ -18,10 +19,10 @@ import (
 
 func TestNodeinfo(t *testing.T) {
 	setting.Federation.Enabled = true
-	c = routers.NormalRoutes()
+	c = routers.NormalRoutes(context.TODO())
 	defer func() {
 		setting.Federation.Enabled = false
-		c = routers.NormalRoutes()
+		c = routers.NormalRoutes(context.TODO())
 	}()
 
 	onGiteaRun(t, func(*testing.T, *url.URL) {
diff --git a/integrations/create_no_session_test.go b/integrations/create_no_session_test.go
index 49234c1e9599..017fe1d356ed 100644
--- a/integrations/create_no_session_test.go
+++ b/integrations/create_no_session_test.go
@@ -5,6 +5,7 @@
 package integrations
 
 import (
+	"context"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -57,7 +58,7 @@ func TestSessionFileCreation(t *testing.T) {
 	oldSessionConfig := setting.SessionConfig.ProviderConfig
 	defer func() {
 		setting.SessionConfig.ProviderConfig = oldSessionConfig
-		c = routers.NormalRoutes()
+		c = routers.NormalRoutes(context.TODO())
 	}()
 
 	var config session.Options
@@ -82,7 +83,7 @@ func TestSessionFileCreation(t *testing.T) {
 
 	setting.SessionConfig.ProviderConfig = string(newConfigBytes)
 
-	c = routers.NormalRoutes()
+	c = routers.NormalRoutes(context.TODO())
 
 	t.Run("NoSessionOnViewIssue", func(t *testing.T) {
 		defer PrintCurrentTest(t)()
diff --git a/integrations/integration_test.go b/integrations/integration_test.go
index 3c379f5c84ef..a506c6a82524 100644
--- a/integrations/integration_test.go
+++ b/integrations/integration_test.go
@@ -89,7 +89,7 @@ func TestMain(m *testing.M) {
 	defer cancel()
 
 	initIntegrationTest()
-	c = routers.NormalRoutes()
+	c = routers.NormalRoutes(context.TODO())
 
 	// integration test settings...
 	if setting.Cfg != nil {
diff --git a/modules/charset/escape_test.go b/modules/charset/escape_test.go
index 8063e115424c..a7232a4658ab 100644
--- a/modules/charset/escape_test.go
+++ b/modules/charset/escape_test.go
@@ -133,11 +133,18 @@ then resh (ר), and finally heh (ה) (which should appear leftmost).`,
 	},
 }
 
+type nullLocale struct{}
+
+func (nullLocale) Language() string                                                   { return "" }
+func (nullLocale) Tr(key string, _ ...interface{}) string                             { return key }
+func (nullLocale) TrN(cnt interface{}, key1, keyN string, args ...interface{}) string { return "" }
+
+var _ (translation.Locale) = nullLocale{}
+
 func TestEscapeControlString(t *testing.T) {
 	for _, tt := range escapeControlTests {
 		t.Run(tt.name, func(t *testing.T) {
-			locale := translation.NewLocale("en_US")
-			status, result := EscapeControlString(tt.text, locale)
+			status, result := EscapeControlString(tt.text, nullLocale{})
 			if !reflect.DeepEqual(*status, tt.status) {
 				t.Errorf("EscapeControlString() status = %v, wanted= %v", status, tt.status)
 			}
@@ -173,7 +180,7 @@ func TestEscapeControlReader(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			input := strings.NewReader(tt.text)
 			output := &strings.Builder{}
-			status, err := EscapeControlReader(input, output, translation.NewLocale("en_US"))
+			status, err := EscapeControlReader(input, output, nullLocale{})
 			result := output.String()
 			if err != nil {
 				t.Errorf("EscapeControlReader(): err = %v", err)
@@ -195,5 +202,5 @@ func TestEscapeControlReader_panic(t *testing.T) {
 	for i := 0; i < 6826; i++ {
 		bs = append(bs, []byte("—")...)
 	}
-	_, _ = EscapeControlString(string(bs), translation.NewLocale("en_US"))
+	_, _ = EscapeControlString(string(bs), nullLocale{})
 }
diff --git a/modules/context/context.go b/modules/context/context.go
index 45f1978e97cc..4b6a21b217c3 100644
--- a/modules/context/context.go
+++ b/modules/context/context.go
@@ -658,8 +658,8 @@ func Auth(authMethod auth.Method) func(*Context) {
 }
 
 // Contexter initializes a classic context for a request.
-func Contexter() func(next http.Handler) http.Handler {
-	rnd := templates.HTMLRenderer()
+func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
+	_, rnd := templates.HTMLRenderer(ctx)
 	csrfOpts := getCsrfOpts()
 	if !setting.IsProd {
 		CsrfTokenRegenerationInterval = 5 * time.Second // in dev, re-generate the tokens more aggressively for debug purpose
diff --git a/modules/context/package.go b/modules/context/package.go
index 92a97831ddc0..ad06f4d63650 100644
--- a/modules/context/package.go
+++ b/modules/context/package.go
@@ -5,6 +5,7 @@
 package context
 
 import (
+	gocontext "context"
 	"fmt"
 	"net/http"
 
@@ -14,6 +15,7 @@ import (
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/templates"
 )
 
 // Package contains owner, access mode and optional the package descriptor
@@ -118,12 +120,14 @@ func packageAssignment(ctx *Context, errCb func(int, string, interface{})) {
 }
 
 // PackageContexter initializes a package context for a request.
-func PackageContexter() func(next http.Handler) http.Handler {
+func PackageContexter(ctx gocontext.Context) func(next http.Handler) http.Handler {
+	_, rnd := templates.HTMLRenderer(ctx)
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
 			ctx := Context{
-				Resp: NewResponse(resp),
-				Data: map[string]interface{}{},
+				Resp:   NewResponse(resp),
+				Data:   map[string]interface{}{},
+				Render: rnd,
 			}
 			defer ctx.Close()
 
diff --git a/modules/options/base.go b/modules/options/base.go
new file mode 100644
index 000000000000..e1d6efa7f026
--- /dev/null
+++ b/modules/options/base.go
@@ -0,0 +1,40 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package options
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+
+	"code.gitea.io/gitea/modules/util"
+)
+
+func walkAssetDir(root string, callback func(path, name string, d fs.DirEntry, err error) error) error {
+	if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+		// name is the path relative to the root
+		name := path[len(root):]
+		if len(name) > 0 && name[0] == '/' {
+			name = name[1:]
+		}
+		if err != nil {
+			if os.IsNotExist(err) {
+				return callback(path, name, d, err)
+			}
+			return err
+		}
+		if util.CommonSkip(d.Name()) {
+			if d.IsDir() {
+				return fs.SkipDir
+			}
+			return nil
+		}
+		return callback(path, name, d, err)
+	}); err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("unable to get files for assets in %s: %w", root, err)
+	}
+	return nil
+}
diff --git a/modules/options/dynamic.go b/modules/options/dynamic.go
index 5fea337e4203..eeef11e8daa2 100644
--- a/modules/options/dynamic.go
+++ b/modules/options/dynamic.go
@@ -8,8 +8,10 @@ package options
 
 import (
 	"fmt"
+	"io/fs"
 	"os"
 	"path"
+	"path/filepath"
 
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -45,7 +47,7 @@ func Dir(name string) ([]string, error) {
 
 	isDir, err = util.IsDir(staticDir)
 	if err != nil {
-		return []string{}, fmt.Errorf("Unabe to check if static directory %s is a directory. %v", staticDir, err)
+		return []string{}, fmt.Errorf("unable to check if static directory %s is a directory. %v", staticDir, err)
 	}
 	if isDir {
 		files, err := util.StatDir(staticDir, true)
@@ -64,6 +66,18 @@ func Locale(name string) ([]byte, error) {
 	return fileFromDir(path.Join("locale", name))
 }
 
+// WalkLocales reads the content of a specific locale from static or custom path.
+func WalkLocales(callback func(path, name string, d fs.DirEntry, err error) error) error {
+	if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("failed to walk locales. Error: %w", err)
+	}
+
+	if err := walkAssetDir(filepath.Join(setting.CustomPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("failed to walk locales. Error: %w", err)
+	}
+	return nil
+}
+
 // Readme reads the content of a specific readme from static or custom path.
 func Readme(name string) ([]byte, error) {
 	return fileFromDir(path.Join("readme", name))
diff --git a/modules/options/static.go b/modules/options/static.go
index 6cad88cb61bb..d9a6c8366405 100644
--- a/modules/options/static.go
+++ b/modules/options/static.go
@@ -9,8 +9,10 @@ package options
 import (
 	"fmt"
 	"io"
+	"io/fs"
 	"os"
 	"path"
+	"path/filepath"
 
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -74,6 +76,14 @@ func Locale(name string) ([]byte, error) {
 	return fileFromDir(path.Join("locale", name))
 }
 
+// WalkLocales reads the content of a specific locale from static or custom path.
+func WalkLocales(callback func(path, name string, d fs.DirEntry, err error) error) error {
+	if err := walkAssetDir(filepath.Join(setting.CustomPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("failed to walk locales. Error: %w", err)
+	}
+	return nil
+}
+
 // Readme reads the content of a specific readme from bindata or custom path.
 func Readme(name string) ([]byte, error) {
 	return fileFromDir(path.Join("readme", name))
diff --git a/modules/templates/base.go b/modules/templates/base.go
index 9563650e127b..d234d531f3dc 100644
--- a/modules/templates/base.go
+++ b/modules/templates/base.go
@@ -5,15 +5,16 @@
 package templates
 
 import (
+	"fmt"
+	"io/fs"
 	"os"
+	"path/filepath"
 	"strings"
 	"time"
 
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
-
-	"github.com/unrolled/render"
 )
 
 // Vars represents variables to be render in golang templates
@@ -47,8 +48,16 @@ func BaseVars() Vars {
 	}
 }
 
-func getDirAssetNames(dir string) []string {
+func getDirTemplateAssetNames(dir string) []string {
+	return getDirAssetNames(dir, false)
+}
+
+func getDirAssetNames(dir string, mailer bool) []string {
 	var tmpls []string
+
+	if mailer {
+		dir += filepath.Join(dir, "mail")
+	}
 	f, err := os.Stat(dir)
 	if err != nil {
 		if os.IsNotExist(err) {
@@ -67,8 +76,13 @@ func getDirAssetNames(dir string) []string {
 		log.Warn("Failed to read %s templates dir. %v", dir, err)
 		return tmpls
 	}
+
+	prefix := "templates/"
+	if mailer {
+		prefix += "mail/"
+	}
 	for _, filePath := range files {
-		if strings.HasPrefix(filePath, "mail/") {
+		if !mailer && strings.HasPrefix(filePath, "mail/") {
 			continue
 		}
 
@@ -76,20 +90,39 @@ func getDirAssetNames(dir string) []string {
 			continue
 		}
 
-		tmpls = append(tmpls, "templates/"+filePath)
+		tmpls = append(tmpls, prefix+filePath)
 	}
 	return tmpls
 }
 
-// HTMLRenderer returns a render.
-func HTMLRenderer() *render.Render {
-	return render.New(render.Options{
-		Extensions:                []string{".tmpl"},
-		Directory:                 "templates",
-		Funcs:                     NewFuncMap(),
-		Asset:                     GetAsset,
-		AssetNames:                GetAssetNames,
-		IsDevelopment:             !setting.IsProd,
-		DisableHTTPErrorRendering: true,
-	})
+func walkAssetDir(root string, skipMail bool, callback func(path, name string, d fs.DirEntry, err error) error) error {
+	mailRoot := filepath.Join(root, "mail")
+	if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+		name := path[len(root):]
+		if len(name) > 0 && name[0] == '/' {
+			name = name[1:]
+		}
+		if err != nil {
+			if os.IsNotExist(err) {
+				return callback(path, name, d, err)
+			}
+			return err
+		}
+		if skipMail && path == mailRoot && d.IsDir() {
+			return fs.SkipDir
+		}
+		if util.CommonSkip(d.Name()) {
+			if d.IsDir() {
+				return fs.SkipDir
+			}
+			return nil
+		}
+		if strings.HasSuffix(d.Name(), ".tmpl") || d.IsDir() {
+			return callback(path, name, d, err)
+		}
+		return nil
+	}); err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("unable to get files for template assets in %s: %w", root, err)
+	}
+	return nil
 }
diff --git a/modules/templates/dynamic.go b/modules/templates/dynamic.go
index de6968c314a0..4896580f6249 100644
--- a/modules/templates/dynamic.go
+++ b/modules/templates/dynamic.go
@@ -8,15 +8,12 @@ package templates
 
 import (
 	"html/template"
+	"io/fs"
 	"os"
-	"path"
 	"path/filepath"
-	"strings"
 	texttmpl "text/template"
 
-	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/util"
 )
 
 var (
@@ -36,77 +33,42 @@ func GetAsset(name string) ([]byte, error) {
 	return os.ReadFile(filepath.Join(setting.StaticRootPath, name))
 }
 
-// GetAssetNames returns assets list
-func GetAssetNames() []string {
-	tmpls := getDirAssetNames(filepath.Join(setting.CustomPath, "templates"))
-	tmpls2 := getDirAssetNames(filepath.Join(setting.StaticRootPath, "templates"))
+// walkTemplateFiles calls a callback for each template asset
+func walkTemplateFiles(callback func(path, name string, d fs.DirEntry, err error) error) error {
+	if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+	if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+	return nil
+}
+
+// GetTemplateAssetNames returns list of template names
+func GetTemplateAssetNames() []string {
+	tmpls := getDirTemplateAssetNames(filepath.Join(setting.CustomPath, "templates"))
+	tmpls2 := getDirTemplateAssetNames(filepath.Join(setting.StaticRootPath, "templates"))
 	return append(tmpls, tmpls2...)
 }
 
-// Mailer provides the templates required for sending notification mails.
-func Mailer() (*texttmpl.Template, *template.Template) {
-	for _, funcs := range NewTextFuncMap() {
-		subjectTemplates.Funcs(funcs)
+func walkMailerTemplates(callback func(path, name string, d fs.DirEntry, err error) error) error {
+	if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
+		return err
 	}
-	for _, funcs := range NewFuncMap() {
-		bodyTemplates.Funcs(funcs)
+	if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
+		return err
 	}
-
-	staticDir := path.Join(setting.StaticRootPath, "templates", "mail")
-
-	isDir, err := util.IsDir(staticDir)
-	if err != nil {
-		log.Warn("Unable to check if templates dir %s is a directory. Error: %v", staticDir, err)
-	}
-	if isDir {
-		files, err := util.StatDir(staticDir)
-
-		if err != nil {
-			log.Warn("Failed to read %s templates dir. %v", staticDir, err)
-		} else {
-			for _, filePath := range files {
-				if !strings.HasSuffix(filePath, ".tmpl") {
-					continue
-				}
-
-				content, err := os.ReadFile(path.Join(staticDir, filePath))
-				if err != nil {
-					log.Warn("Failed to read static %s template. %v", filePath, err)
-					continue
-				}
-
-				buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content)
-			}
-		}
-	}
-
-	customDir := path.Join(setting.CustomPath, "templates", "mail")
-
-	isDir, err = util.IsDir(customDir)
-	if err != nil {
-		log.Warn("Unable to check if templates dir %s is a directory. Error: %v", customDir, err)
-	}
-	if isDir {
-		files, err := util.StatDir(customDir)
-
-		if err != nil {
-			log.Warn("Failed to read %s templates dir. %v", customDir, err)
-		} else {
-			for _, filePath := range files {
-				if !strings.HasSuffix(filePath, ".tmpl") {
-					continue
-				}
-
-				content, err := os.ReadFile(path.Join(customDir, filePath))
-				if err != nil {
-					log.Warn("Failed to read custom %s template. %v", filePath, err)
-					continue
-				}
-
-				buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content)
-			}
-		}
-	}
-
-	return subjectTemplates, bodyTemplates
+	return nil
+}
+
+// BuiltinAsset will read the provided asset from the embedded assets
+// (This always returns os.ErrNotExist)
+func BuiltinAsset(name string) ([]byte, error) {
+	return nil, os.ErrNotExist
+}
+
+// BuiltinAssetNames returns the names of the embedded assets
+// (This always returns nil)
+func BuiltinAssetNames() []string {
+	return nil
 }
diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go
new file mode 100644
index 000000000000..210bb5e73c7e
--- /dev/null
+++ b/modules/templates/htmlrenderer.go
@@ -0,0 +1,52 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package templates
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/watcher"
+
+	"github.com/unrolled/render"
+)
+
+var rendererKey interface{} = "templatesHtmlRendereer"
+
+// HTMLRenderer returns the current html renderer for the context or creates and stores one within the context for future use
+func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
+	rendererInterface := ctx.Value(rendererKey)
+	if rendererInterface != nil {
+		renderer, ok := rendererInterface.(*render.Render)
+		if ok {
+			return ctx, renderer
+		}
+	}
+
+	rendererType := "static"
+	if !setting.IsProd {
+		rendererType = "auto-reloading"
+	}
+	log.Log(1, log.DEBUG, "Creating "+rendererType+" HTML Renderer")
+
+	renderer := render.New(render.Options{
+		Extensions:                []string{".tmpl"},
+		Directory:                 "templates",
+		Funcs:                     NewFuncMap(),
+		Asset:                     GetAsset,
+		AssetNames:                GetTemplateAssetNames,
+		UseMutexLock:              !setting.IsProd,
+		IsDevelopment:             false,
+		DisableHTTPErrorRendering: true,
+	})
+	if !setting.IsProd {
+		watcher.CreateWatcher(ctx, "HTML Templates", &watcher.CreateWatcherOpts{
+			PathsCallback:   walkTemplateFiles,
+			BetweenCallback: renderer.CompileTemplates,
+		})
+	}
+	return context.WithValue(ctx, rendererKey, renderer), renderer
+}
diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go
new file mode 100644
index 000000000000..0cac1280f344
--- /dev/null
+++ b/modules/templates/mailer.go
@@ -0,0 +1,92 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package templates
+
+import (
+	"context"
+	"html/template"
+	"io/fs"
+	"os"
+	"strings"
+	texttmpl "text/template"
+
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/watcher"
+)
+
+// Mailer provides the templates required for sending notification mails.
+func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
+	for _, funcs := range NewTextFuncMap() {
+		subjectTemplates.Funcs(funcs)
+	}
+	for _, funcs := range NewFuncMap() {
+		bodyTemplates.Funcs(funcs)
+	}
+
+	refreshTemplates := func() {
+		for _, assetPath := range BuiltinAssetNames() {
+			if !strings.HasPrefix(assetPath, "mail/") {
+				continue
+			}
+
+			if !strings.HasSuffix(assetPath, ".tmpl") {
+				continue
+			}
+
+			content, err := BuiltinAsset(assetPath)
+			if err != nil {
+				log.Warn("Failed to read embedded %s template. %v", assetPath, err)
+				continue
+			}
+
+			assetName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/")
+
+			log.Trace("Adding built-in mailer template for %s", assetName)
+			buildSubjectBodyTemplate(subjectTemplates,
+				bodyTemplates,
+				assetName,
+				content)
+		}
+
+		if err := walkMailerTemplates(func(path, name string, d fs.DirEntry, err error) error {
+			if err != nil {
+				return err
+			}
+			if d.IsDir() {
+				return nil
+			}
+
+			content, err := os.ReadFile(path)
+			if err != nil {
+				log.Warn("Failed to read custom %s template. %v", path, err)
+				return nil
+			}
+
+			assetName := strings.TrimSuffix(name, ".tmpl")
+			log.Trace("Adding mailer template for %s from %q", assetName, path)
+			buildSubjectBodyTemplate(subjectTemplates,
+				bodyTemplates,
+				assetName,
+				content)
+			return nil
+		}); err != nil && !os.IsNotExist(err) {
+			log.Warn("Error whilst walking mailer templates directories. %v", err)
+		}
+	}
+
+	refreshTemplates()
+
+	if !setting.IsProd {
+		// Now subjectTemplates and bodyTemplates are both synchronized
+		// thus it is safe to call refresh from a different goroutine
+		watcher.CreateWatcher(ctx, "Mailer Templates", &watcher.CreateWatcherOpts{
+			PathsCallback:   walkMailerTemplates,
+			BetweenCallback: refreshTemplates,
+		})
+	}
+
+	return subjectTemplates, bodyTemplates
+}
diff --git a/modules/templates/static.go b/modules/templates/static.go
index 351e48b4daa9..3265bd9cfcbc 100644
--- a/modules/templates/static.go
+++ b/modules/templates/static.go
@@ -9,6 +9,7 @@ package templates
 import (
 	"html/template"
 	"io"
+	"io/fs"
 	"os"
 	"path"
 	"path/filepath"
@@ -16,10 +17,8 @@ import (
 	texttmpl "text/template"
 	"time"
 
-	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
-	"code.gitea.io/gitea/modules/util"
 )
 
 var (
@@ -40,95 +39,42 @@ func GetAsset(name string) ([]byte, error) {
 	} else if err == nil {
 		return bs, nil
 	}
-	return Asset(strings.TrimPrefix(name, "templates/"))
+	return BuiltinAsset(strings.TrimPrefix(name, "templates/"))
 }
 
-// GetAssetNames only for chi
-func GetAssetNames() []string {
+// GetFiles calls a callback for each template asset
+func walkTemplateFiles(callback func(path, name string, d fs.DirEntry, err error) error) error {
+	if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+	return nil
+}
+
+// GetTemplateAssetNames only for chi
+func GetTemplateAssetNames() []string {
 	realFS := Assets.(vfsgen۰FS)
 	tmpls := make([]string, 0, len(realFS))
 	for k := range realFS {
+		if strings.HasPrefix(k, "/mail/") {
+			continue
+		}
 		tmpls = append(tmpls, "templates/"+k[1:])
 	}
 
 	customDir := path.Join(setting.CustomPath, "templates")
-	customTmpls := getDirAssetNames(customDir)
+	customTmpls := getDirTemplateAssetNames(customDir)
 	return append(tmpls, customTmpls...)
 }
 
-// Mailer provides the templates required for sending notification mails.
-func Mailer() (*texttmpl.Template, *template.Template) {
-	for _, funcs := range NewTextFuncMap() {
-		subjectTemplates.Funcs(funcs)
+func walkMailerTemplates(callback func(path, name string, d fs.DirEntry, err error) error) error {
+	if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
+		return err
 	}
-	for _, funcs := range NewFuncMap() {
-		bodyTemplates.Funcs(funcs)
-	}
-
-	for _, assetPath := range AssetNames() {
-		if !strings.HasPrefix(assetPath, "mail/") {
-			continue
-		}
-
-		if !strings.HasSuffix(assetPath, ".tmpl") {
-			continue
-		}
-
-		content, err := Asset(assetPath)
-		if err != nil {
-			log.Warn("Failed to read embedded %s template. %v", assetPath, err)
-			continue
-		}
-
-		buildSubjectBodyTemplate(subjectTemplates,
-			bodyTemplates,
-			strings.TrimPrefix(
-				strings.TrimSuffix(
-					assetPath,
-					".tmpl",
-				),
-				"mail/",
-			),
-			content)
-	}
-
-	customDir := path.Join(setting.CustomPath, "templates", "mail")
-	isDir, err := util.IsDir(customDir)
-	if err != nil {
-		log.Warn("Failed to check if custom directory %s is a directory. %v", err)
-	}
-	if isDir {
-		files, err := util.StatDir(customDir)
-
-		if err != nil {
-			log.Warn("Failed to read %s templates dir. %v", customDir, err)
-		} else {
-			for _, filePath := range files {
-				if !strings.HasSuffix(filePath, ".tmpl") {
-					continue
-				}
-
-				content, err := os.ReadFile(path.Join(customDir, filePath))
-				if err != nil {
-					log.Warn("Failed to read custom %s template. %v", filePath, err)
-					continue
-				}
-
-				buildSubjectBodyTemplate(subjectTemplates,
-					bodyTemplates,
-					strings.TrimSuffix(
-						filePath,
-						".tmpl",
-					),
-					content)
-			}
-		}
-	}
-
-	return subjectTemplates, bodyTemplates
+	return nil
 }
 
-func Asset(name string) ([]byte, error) {
+// BuiltinAsset reads the provided asset from the builtin embedded assets
+func BuiltinAsset(name string) ([]byte, error) {
 	f, err := Assets.Open("/" + name)
 	if err != nil {
 		return nil, err
@@ -137,7 +83,8 @@ func Asset(name string) ([]byte, error) {
 	return io.ReadAll(f)
 }
 
-func AssetNames() []string {
+// BuiltinAssetNames returns the names of the built-in embedded assets
+func BuiltinAssetNames() []string {
 	realFS := Assets.(vfsgen۰FS)
 	results := make([]string, 0, len(realFS))
 	for k := range realFS {
@@ -146,7 +93,8 @@ func AssetNames() []string {
 	return results
 }
 
-func AssetIsDir(name string) (bool, error) {
+// BuiltinAssetIsDir returns if a provided asset is a directory
+func BuiltinAssetIsDir(name string) (bool, error) {
 	if f, err := Assets.Open("/" + name); err != nil {
 		return false, err
 	} else {
diff --git a/modules/timeutil/since_test.go b/modules/timeutil/since_test.go
index dfcf9cb01d98..9350b5e96b9e 100644
--- a/modules/timeutil/since_test.go
+++ b/modules/timeutil/since_test.go
@@ -5,6 +5,7 @@
 package timeutil
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"testing"
@@ -31,7 +32,7 @@ func TestMain(m *testing.M) {
 	setting.Names = []string{"english"}
 	setting.Langs = []string{"en-US"}
 	// setup
-	translation.InitLocales()
+	translation.InitLocales(context.Background())
 	BaseDate = time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
 
 	// run the tests
diff --git a/modules/translation/i18n/errors.go b/modules/translation/i18n/errors.go
new file mode 100644
index 000000000000..b485badd1d2b
--- /dev/null
+++ b/modules/translation/i18n/errors.go
@@ -0,0 +1,12 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package i18n
+
+import "errors"
+
+var (
+	ErrLocaleAlreadyExist = errors.New("lang already exists")
+	ErrUncertainArguments = errors.New("arguments to i18n should not contain uncertain slices")
+)
diff --git a/modules/translation/i18n/format.go b/modules/translation/i18n/format.go
new file mode 100644
index 000000000000..3fb9e6d6d05f
--- /dev/null
+++ b/modules/translation/i18n/format.go
@@ -0,0 +1,42 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package i18n
+
+import (
+	"fmt"
+	"reflect"
+)
+
+// Format formats provided arguments for a given translated message
+func Format(format string, args ...interface{}) (msg string, err error) {
+	if len(args) == 0 {
+		return format, nil
+	}
+
+	fmtArgs := make([]interface{}, 0, len(args))
+	for _, arg := range args {
+		val := reflect.ValueOf(arg)
+		if val.Kind() == reflect.Slice {
+			// Previously, we would accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f)
+			// but this is an unstable behavior.
+			//
+			// So we restrict the accepted arguments to either:
+			//
+			// 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...)
+			// 2. Tr(lang, key, args...) as Sprintf(msg, args...)
+			if len(args) == 1 {
+				for i := 0; i < val.Len(); i++ {
+					fmtArgs = append(fmtArgs, val.Index(i).Interface())
+				}
+			} else {
+				err = ErrUncertainArguments
+				break
+			}
+		} else {
+			fmtArgs = append(fmtArgs, arg)
+		}
+	}
+	return fmt.Sprintf(format, fmtArgs...), err
+}
diff --git a/modules/translation/i18n/i18n.go b/modules/translation/i18n/i18n.go
index bb906f3c08c1..23b4e23c7644 100644
--- a/modules/translation/i18n/i18n.go
+++ b/modules/translation/i18n/i18n.go
@@ -5,297 +5,48 @@
 package i18n
 
 import (
-	"errors"
-	"fmt"
-	"os"
-	"reflect"
-	"sync"
-	"time"
-
-	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/setting"
-
-	"gopkg.in/ini.v1"
+	"io"
 )
 
-var (
-	ErrLocaleAlreadyExist = errors.New("lang already exists")
+var DefaultLocales = NewLocaleStore()
 
-	DefaultLocales = NewLocaleStore(true)
-)
-
-type locale struct {
-	// This mutex will be set if we have live-reload enabled (e.g. dev mode)
-	reloadMu *sync.RWMutex
-
-	store    *LocaleStore
-	langName string
-
-	idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
-
-	sourceFileName      string
-	sourceFileInfo      os.FileInfo
-	lastReloadCheckTime time.Time
+type Locale interface {
+	// Tr translates a given key and arguments for a language
+	Tr(trKey string, trArgs ...interface{}) string
+	// Has reports if a locale has a translation for a given key
+	Has(trKey string) bool
 }
 
-type LocaleStore struct {
-	// This mutex will be set if we have live-reload enabled (e.g. dev mode)
-	reloadMu *sync.RWMutex
+// LocaleStore provides the functions common to all locale stores
+type LocaleStore interface {
+	io.Closer
 
-	langNames []string
-	langDescs []string
-	localeMap map[string]*locale
-
-	// this needs to be locked when live-reloading
-	trKeyToIdxMap map[string]int
-
-	defaultLang string
-}
-
-func NewLocaleStore(isProd bool) *LocaleStore {
-	store := &LocaleStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)}
-	if !isProd {
-		store.reloadMu = &sync.RWMutex{}
-	}
-	return store
-}
-
-// AddLocaleByIni adds locale by ini into the store
-// if source is a string, then the file is loaded. In dev mode, this file will be checked for live-reloading
-// if source is a []byte, then the content is used
-// Note: this is not concurrent safe
-func (store *LocaleStore) AddLocaleByIni(langName, langDesc string, source interface{}) error {
-	if _, ok := store.localeMap[langName]; ok {
-		return ErrLocaleAlreadyExist
-	}
-
-	l := &locale{store: store, langName: langName}
-	if store.reloadMu != nil {
-		l.reloadMu = &sync.RWMutex{}
-		l.reloadMu.Lock() // Arguably this is not necessary as AddLocaleByIni isn't concurrent safe - but for consistency we do this
-		defer l.reloadMu.Unlock()
-	}
-
-	if fileName, ok := source.(string); ok {
-		l.sourceFileName = fileName
-		l.sourceFileInfo, _ = os.Stat(fileName) // live-reload only works for regular files. the error can be ignored
-	}
-
-	var err error
-	l.idxToMsgMap, err = store.readIniToIdxToMsgMap(source)
-	if err != nil {
-		return err
-	}
-
-	store.langNames = append(store.langNames, langName)
-	store.langDescs = append(store.langDescs, langDesc)
-
-	store.localeMap[l.langName] = l
-
-	return nil
-}
-
-// readIniToIdxToMsgMap will read a provided ini and creates an idxToMsgMap
-func (store *LocaleStore) readIniToIdxToMsgMap(source interface{}) (map[int]string, error) {
-	iniFile, err := ini.LoadSources(ini.LoadOptions{
-		IgnoreInlineComment:         true,
-		UnescapeValueCommentSymbols: true,
-	}, source)
-	if err != nil {
-		return nil, fmt.Errorf("unable to load ini: %w", err)
-	}
-	iniFile.BlockMode = false
-
-	idxToMsgMap := make(map[int]string)
-
-	if store.reloadMu != nil {
-		store.reloadMu.Lock()
-		defer store.reloadMu.Unlock()
-	}
-
-	for _, section := range iniFile.Sections() {
-		for _, key := range section.Keys() {
-
-			var trKey string
-			if section.Name() == "" || section.Name() == "DEFAULT" {
-				trKey = key.Name()
-			} else {
-				trKey = section.Name() + "." + key.Name()
-			}
-
-			// Instead of storing the key strings in multiple different maps we compute a idx which will act as numeric code for key
-			// This reduces the size of the locale idxToMsgMaps
-			idx, ok := store.trKeyToIdxMap[trKey]
-			if !ok {
-				idx = len(store.trKeyToIdxMap)
-				store.trKeyToIdxMap[trKey] = idx
-			}
-			idxToMsgMap[idx] = key.Value()
-		}
-	}
-	iniFile = nil
-	return idxToMsgMap, nil
-}
-
-func (store *LocaleStore) idxForTrKey(trKey string) (int, bool) {
-	if store.reloadMu != nil {
-		store.reloadMu.RLock()
-		defer store.reloadMu.RUnlock()
-	}
-	idx, ok := store.trKeyToIdxMap[trKey]
-	return idx, ok
-}
-
-// HasLang reports if a language is available in the store
-func (store *LocaleStore) HasLang(langName string) bool {
-	_, ok := store.localeMap[langName]
-	return ok
-}
-
-// ListLangNameDesc reports if a language available in the store
-func (store *LocaleStore) ListLangNameDesc() (names, desc []string) {
-	return store.langNames, store.langDescs
-}
-
-// SetDefaultLang sets default language as a fallback
-func (store *LocaleStore) SetDefaultLang(lang string) {
-	store.defaultLang = lang
-}
-
-// Tr translates content to target language. fall back to default language.
-func (store *LocaleStore) Tr(lang, trKey string, trArgs ...interface{}) string {
-	l, ok := store.localeMap[lang]
-	if !ok {
-		l, ok = store.localeMap[store.defaultLang]
-	}
-
-	if ok {
-		return l.Tr(trKey, trArgs...)
-	}
-	return trKey
-}
-
-// reloadIfNeeded will check if the locale needs to be reloaded
-// this function will assume that the l.reloadMu has been RLocked if it already exists
-func (l *locale) reloadIfNeeded() {
-	if l.reloadMu == nil {
-		return
-	}
-
-	now := time.Now()
-	if now.Sub(l.lastReloadCheckTime) < time.Second || l.sourceFileInfo == nil || l.sourceFileName == "" {
-		return
-	}
-
-	l.reloadMu.RUnlock()
-	l.reloadMu.Lock() // (NOTE: a pre-emption can occur between these two locks so we need to recheck)
-	defer l.reloadMu.RLock()
-	defer l.reloadMu.Unlock()
-
-	if now.Sub(l.lastReloadCheckTime) < time.Second || l.sourceFileInfo == nil || l.sourceFileName == "" {
-		return
-	}
-
-	l.lastReloadCheckTime = now
-	sourceFileInfo, err := os.Stat(l.sourceFileName)
-	if err != nil || sourceFileInfo.ModTime().Equal(l.sourceFileInfo.ModTime()) {
-		return
-	}
-
-	idxToMsgMap, err := l.store.readIniToIdxToMsgMap(l.sourceFileName)
-	if err == nil {
-		l.idxToMsgMap = idxToMsgMap
-	} else {
-		log.Error("Unable to live-reload the locale file %q, err: %v", l.sourceFileName, err)
-	}
-
-	// We will set the sourceFileInfo to this file to prevent repeated attempts to re-load this broken file
-	l.sourceFileInfo = sourceFileInfo
-}
-
-// Tr translates content to locale language. fall back to default language.
-func (l *locale) Tr(trKey string, trArgs ...interface{}) string {
-	if l.reloadMu != nil {
-		l.reloadMu.RLock()
-		defer l.reloadMu.RUnlock()
-		l.reloadIfNeeded()
-	}
-
-	msg, _ := l.tryTr(trKey, trArgs...)
-	return msg
-}
-
-func (l *locale) tryTr(trKey string, trArgs ...interface{}) (msg string, found bool) {
-	trMsg := trKey
-
-	// convert the provided trKey to a common idx from the store
-	idx, ok := l.store.idxForTrKey(trKey)
-
-	if ok {
-		if msg, found = l.idxToMsgMap[idx]; found {
-			trMsg = msg // use the translation that we have found
-		} else if l.langName != l.store.defaultLang {
-			// No translation available in our current language... fallback to the default language
-
-			// Attempt to get the default language from the locale store
-			if def, ok := l.store.localeMap[l.store.defaultLang]; ok {
-
-				if def.reloadMu != nil {
-					def.reloadMu.RLock()
-					def.reloadIfNeeded()
-				}
-				if msg, found = def.idxToMsgMap[idx]; found {
-					trMsg = msg // use the translation that we have found
-				}
-				if def.reloadMu != nil {
-					def.reloadMu.RUnlock()
-				}
-			}
-		}
-	}
-
-	if !found && !setting.IsProd {
-		log.Error("missing i18n translation key: %q", trKey)
-	}
-
-	if len(trArgs) == 0 {
-		return trMsg, found
-	}
-
-	fmtArgs := make([]interface{}, 0, len(trArgs))
-	for _, arg := range trArgs {
-		val := reflect.ValueOf(arg)
-		if val.Kind() == reflect.Slice {
-			// Previously, we would accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f)
-			// but this is an unstable behavior.
-			//
-			// So we restrict the accepted arguments to either:
-			//
-			// 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...)
-			// 2. Tr(lang, key, args...) as Sprintf(msg, args...)
-			if len(trArgs) == 1 {
-				for i := 0; i < val.Len(); i++ {
-					fmtArgs = append(fmtArgs, val.Index(i).Interface())
-				}
-			} else {
-				log.Error("the args for i18n shouldn't contain uncertain slices, key=%q, args=%v", trKey, trArgs)
-				break
-			}
-		} else {
-			fmtArgs = append(fmtArgs, arg)
-		}
-	}
-
-	return fmt.Sprintf(trMsg, fmtArgs...), found
+	// Tr translates a given key and arguments for a language
+	Tr(lang, trKey string, trArgs ...interface{}) string
+	// Has reports if a locale has a translation for a given key
+	Has(lang, trKey string) bool
+	// SetDefaultLang sets the default language to fall back to
+	SetDefaultLang(lang string)
+	// ListLangNameDesc provides paired slices of language names to descriptors
+	ListLangNameDesc() (names, desc []string)
+	// Locale return the locale for the provided language or the default language if not found
+	Locale(langName string) (Locale, bool)
+	// HasLang returns whether a given language is present in the store
+	HasLang(langName string) bool
+	// AddLocaleByIni adds a new language to the store
+	AddLocaleByIni(langName, langDesc string, source interface{}) error
 }
 
 // ResetDefaultLocales resets the current default locales
 // NOTE: this is not synchronized
-func ResetDefaultLocales(isProd bool) {
-	DefaultLocales = NewLocaleStore(isProd)
+func ResetDefaultLocales() {
+	if DefaultLocales != nil {
+		_ = DefaultLocales.Close()
+	}
+	DefaultLocales = NewLocaleStore()
 }
 
-// Tr use default locales to translate content to target language.
-func Tr(lang, trKey string, trArgs ...interface{}) string {
-	return DefaultLocales.Tr(lang, trKey, trArgs...)
+// GetLocales returns the locale from the default locales
+func GetLocale(lang string) (Locale, bool) {
+	return DefaultLocales.Locale(lang)
 }
diff --git a/modules/translation/i18n/i18n_test.go b/modules/translation/i18n/i18n_test.go
index 32f7585b322e..7940e59c940a 100644
--- a/modules/translation/i18n/i18n_test.go
+++ b/modules/translation/i18n/i18n_test.go
@@ -27,36 +27,34 @@ fmt = %[2]s %[1]s
 sub = Changed Sub String
 `)
 
-	for _, isProd := range []bool{true, false} {
-		ls := NewLocaleStore(isProd)
-		assert.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1))
-		assert.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2))
-		ls.SetDefaultLang("lang1")
+	ls := NewLocaleStore()
+	assert.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1))
+	assert.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2))
+	ls.SetDefaultLang("lang1")
 
-		result := ls.Tr("lang1", "fmt", "a", "b")
-		assert.Equal(t, "a b", result)
+	result := ls.Tr("lang1", "fmt", "a", "b")
+	assert.Equal(t, "a b", result)
 
-		result = ls.Tr("lang2", "fmt", "a", "b")
-		assert.Equal(t, "b a", result)
+	result = ls.Tr("lang2", "fmt", "a", "b")
+	assert.Equal(t, "b a", result)
 
-		result = ls.Tr("lang1", "section.sub")
-		assert.Equal(t, "Sub String", result)
+	result = ls.Tr("lang1", "section.sub")
+	assert.Equal(t, "Sub String", result)
 
-		result = ls.Tr("lang2", "section.sub")
-		assert.Equal(t, "Changed Sub String", result)
+	result = ls.Tr("lang2", "section.sub")
+	assert.Equal(t, "Changed Sub String", result)
 
-		result = ls.Tr("", ".dot.name")
-		assert.Equal(t, "Dot Name", result)
+	result = ls.Tr("", ".dot.name")
+	assert.Equal(t, "Dot Name", result)
 
-		result = ls.Tr("lang2", "section.mixed")
-		assert.Equal(t, `test value; <span style="color: red; background: none;">more text</span>`, result)
+	result = ls.Tr("lang2", "section.mixed")
+	assert.Equal(t, `test value; <span style="color: red; background: none;">more text</span>`, result)
 
-		langs, descs := ls.ListLangNameDesc()
-		assert.Equal(t, []string{"lang1", "lang2"}, langs)
-		assert.Equal(t, []string{"Lang1", "Lang2"}, descs)
+	langs, descs := ls.ListLangNameDesc()
+	assert.Equal(t, []string{"lang1", "lang2"}, langs)
+	assert.Equal(t, []string{"Lang1", "Lang2"}, descs)
 
-		result, found := ls.localeMap["lang1"].tryTr("no-such")
-		assert.Equal(t, "no-such", result)
-		assert.False(t, found)
-	}
+	found := ls.Has("lang1", "no-such")
+	assert.False(t, found)
+	assert.NoError(t, ls.Close())
 }
diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go
new file mode 100644
index 000000000000..e3b88ad96eba
--- /dev/null
+++ b/modules/translation/i18n/localestore.go
@@ -0,0 +1,161 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package i18n
+
+import (
+	"fmt"
+
+	"code.gitea.io/gitea/modules/log"
+
+	"gopkg.in/ini.v1"
+)
+
+// This file implements the static LocaleStore that will not watch for changes
+
+type locale struct {
+	store       *localeStore
+	langName    string
+	idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
+}
+
+type localeStore struct {
+	// After initializing has finished, these fields are read-only.
+	langNames []string
+	langDescs []string
+
+	localeMap     map[string]*locale
+	trKeyToIdxMap map[string]int
+
+	defaultLang string
+}
+
+// NewLocaleStore creates a static locale store
+func NewLocaleStore() LocaleStore {
+	return &localeStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)}
+}
+
+// AddLocaleByIni adds locale by ini into the store
+// if source is a string, then the file is loaded
+// if source is a []byte, then the content is used
+func (store *localeStore) AddLocaleByIni(langName, langDesc string, source interface{}) error {
+	if _, ok := store.localeMap[langName]; ok {
+		return ErrLocaleAlreadyExist
+	}
+
+	store.langNames = append(store.langNames, langName)
+	store.langDescs = append(store.langDescs, langDesc)
+
+	l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string)}
+	store.localeMap[l.langName] = l
+
+	iniFile, err := ini.LoadSources(ini.LoadOptions{
+		IgnoreInlineComment:         true,
+		UnescapeValueCommentSymbols: true,
+	}, source)
+	if err != nil {
+		return fmt.Errorf("unable to load ini: %w", err)
+	}
+	iniFile.BlockMode = false
+
+	for _, section := range iniFile.Sections() {
+		for _, key := range section.Keys() {
+			var trKey string
+			if section.Name() == "" || section.Name() == "DEFAULT" {
+				trKey = key.Name()
+			} else {
+				trKey = section.Name() + "." + key.Name()
+			}
+			idx, ok := store.trKeyToIdxMap[trKey]
+			if !ok {
+				idx = len(store.trKeyToIdxMap)
+				store.trKeyToIdxMap[trKey] = idx
+			}
+			l.idxToMsgMap[idx] = key.Value()
+		}
+	}
+	iniFile = nil
+
+	return nil
+}
+
+func (store *localeStore) HasLang(langName string) bool {
+	_, ok := store.localeMap[langName]
+	return ok
+}
+
+func (store *localeStore) ListLangNameDesc() (names, desc []string) {
+	return store.langNames, store.langDescs
+}
+
+// SetDefaultLang sets default language as a fallback
+func (store *localeStore) SetDefaultLang(lang string) {
+	store.defaultLang = lang
+}
+
+// Tr translates content to target language. fall back to default language.
+func (store *localeStore) Tr(lang, trKey string, trArgs ...interface{}) string {
+	l, _ := store.Locale(lang)
+
+	return l.Tr(trKey, trArgs...)
+}
+
+// Has returns whether the given language has a translation for the provided key
+func (store *localeStore) Has(lang, trKey string) bool {
+	l, _ := store.Locale(lang)
+
+	return l.Has(trKey)
+}
+
+// Locale returns the locale for the lang or the default language
+func (store *localeStore) Locale(lang string) (Locale, bool) {
+	l, found := store.localeMap[lang]
+	if !found {
+		var ok bool
+		l, ok = store.localeMap[store.defaultLang]
+		if !ok {
+			// no default - return an empty locale
+			l = &locale{store: store, idxToMsgMap: make(map[int]string)}
+		}
+	}
+	return l, found
+}
+
+// Close implements io.Closer
+func (store *localeStore) Close() error {
+	return nil
+}
+
+// Tr translates content to locale language. fall back to default language.
+func (l *locale) Tr(trKey string, trArgs ...interface{}) string {
+	format := trKey
+
+	idx, ok := l.store.trKeyToIdxMap[trKey]
+	if ok {
+		if msg, ok := l.idxToMsgMap[idx]; ok {
+			format = msg // use the found translation
+		} else if def, ok := l.store.localeMap[l.store.defaultLang]; ok {
+			// try to use default locale's translation
+			if msg, ok := def.idxToMsgMap[idx]; ok {
+				format = msg
+			}
+		}
+	}
+
+	msg, err := Format(format, trArgs...)
+	if err != nil {
+		log.Error("Error whilst formatting %q in %s: %v", trKey, l.langName, err)
+	}
+	return msg
+}
+
+// Has returns whether a key is present in this locale or not
+func (l *locale) Has(trKey string) bool {
+	idx, ok := l.store.trKeyToIdxMap[trKey]
+	if !ok {
+		return false
+	}
+	_, ok = l.idxToMsgMap[idx]
+	return ok
+}
diff --git a/modules/translation/translation.go b/modules/translation/translation.go
index fcc101d96343..e40a9357faef 100644
--- a/modules/translation/translation.go
+++ b/modules/translation/translation.go
@@ -5,15 +5,16 @@
 package translation
 
 import (
-	"path"
+	"context"
 	"sort"
 	"strings"
+	"sync"
 
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/options"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/translation/i18n"
-	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/watcher"
 
 	"golang.org/x/text/language"
 )
@@ -31,6 +32,7 @@ type LangType struct {
 }
 
 var (
+	lock          *sync.RWMutex
 	matcher       language.Matcher
 	allLangs      []*LangType
 	allLangMap    map[string]*LangType
@@ -43,57 +45,52 @@ func AllLangs() []*LangType {
 }
 
 // InitLocales loads the locales
-func InitLocales() {
-	i18n.ResetDefaultLocales(setting.IsProd)
-	localeNames, err := options.Dir("locale")
-	if err != nil {
-		log.Fatal("Failed to list locale files: %v", err)
+func InitLocales(ctx context.Context) {
+	if lock != nil {
+		lock.Lock()
+		defer lock.Unlock()
+	} else if !setting.IsProd && lock == nil {
+		lock = &sync.RWMutex{}
 	}
 
-	localFiles := make(map[string]interface{}, len(localeNames))
-	for _, name := range localeNames {
-		if options.IsDynamic() {
-			// Try to check if CustomPath has the file, otherwise fallback to StaticRootPath
-			value := path.Join(setting.CustomPath, "options/locale", name)
+	refreshLocales := func() {
+		i18n.ResetDefaultLocales()
+		localeNames, err := options.Dir("locale")
+		if err != nil {
+			log.Fatal("Failed to list locale files: %v", err)
+		}
 
-			isFile, err := util.IsFile(value)
-			if err != nil {
-				log.Fatal("Failed to load %s locale file. %v", name, err)
-			}
-
-			if isFile {
-				localFiles[name] = value
-			} else {
-				localFiles[name] = path.Join(setting.StaticRootPath, "options/locale", name)
-			}
-		} else {
+		localFiles := make(map[string]interface{}, len(localeNames))
+		for _, name := range localeNames {
 			localFiles[name], err = options.Locale(name)
 			if err != nil {
 				log.Fatal("Failed to load %s locale file. %v", name, err)
 			}
 		}
-	}
 
-	supportedTags = make([]language.Tag, len(setting.Langs))
-	for i, lang := range setting.Langs {
-		supportedTags[i] = language.Raw.Make(lang)
-	}
+		supportedTags = make([]language.Tag, len(setting.Langs))
+		for i, lang := range setting.Langs {
+			supportedTags[i] = language.Raw.Make(lang)
+		}
 
-	matcher = language.NewMatcher(supportedTags)
-	for i := range setting.Names {
-		key := "locale_" + setting.Langs[i] + ".ini"
+		matcher = language.NewMatcher(supportedTags)
+		for i := range setting.Names {
+			key := "locale_" + setting.Langs[i] + ".ini"
 
-		if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localFiles[key]); err != nil {
-			log.Error("Failed to set messages to %s: %v", setting.Langs[i], err)
+			if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localFiles[key]); err != nil {
+				log.Error("Failed to set messages to %s: %v", setting.Langs[i], err)
+			}
+		}
+		if len(setting.Langs) != 0 {
+			defaultLangName := setting.Langs[0]
+			if defaultLangName != "en-US" {
+				log.Info("Use the first locale (%s) in LANGS setting option as default", defaultLangName)
+			}
+			i18n.DefaultLocales.SetDefaultLang(defaultLangName)
 		}
 	}
-	if len(setting.Langs) != 0 {
-		defaultLangName := setting.Langs[0]
-		if defaultLangName != "en-US" {
-			log.Info("Use the first locale (%s) in LANGS setting option as default", defaultLangName)
-		}
-		i18n.DefaultLocales.SetDefaultLang(defaultLangName)
-	}
+
+	refreshLocales()
 
 	langs, descs := i18n.DefaultLocales.ListLangNameDesc()
 	allLangs = make([]*LangType, 0, len(langs))
@@ -108,6 +105,17 @@ func InitLocales() {
 	sort.Slice(allLangs, func(i, j int) bool {
 		return strings.ToLower(allLangs[i].Name) < strings.ToLower(allLangs[j].Name)
 	})
+
+	if !setting.IsProd {
+		watcher.CreateWatcher(ctx, "Locales", &watcher.CreateWatcherOpts{
+			PathsCallback: options.WalkLocales,
+			BetweenCallback: func() {
+				lock.Lock()
+				defer lock.Unlock()
+				refreshLocales()
+			},
+		})
+	}
 }
 
 // Match matches accept languages
@@ -118,16 +126,24 @@ func Match(tags ...language.Tag) language.Tag {
 
 // locale represents the information of localization.
 type locale struct {
+	i18n.Locale
 	Lang, LangName string // these fields are used directly in templates: .i18n.Lang
 }
 
 // NewLocale return a locale
 func NewLocale(lang string) Locale {
+	if lock != nil {
+		lock.RLock()
+		defer lock.RUnlock()
+	}
+
 	langName := "unknown"
 	if l, ok := allLangMap[lang]; ok {
 		langName = l.Name
 	}
+	i18nLocale, _ := i18n.GetLocale(lang)
 	return &locale{
+		Locale:   i18nLocale,
 		Lang:     lang,
 		LangName: langName,
 	}
@@ -137,11 +153,6 @@ func (l *locale) Language() string {
 	return l.Lang
 }
 
-// Tr translates content to target language.
-func (l *locale) Tr(format string, args ...interface{}) string {
-	return i18n.Tr(l.Lang, format, args...)
-}
-
 // Language specific rules for translating plural texts
 var trNLangRules = map[string]func(int64) int{
 	// the default rule is "en-US" if a language isn't listed here
diff --git a/modules/util/path.go b/modules/util/path.go
index 0ccc7a1dc2ac..3d4ddec21cb2 100644
--- a/modules/util/path.go
+++ b/modules/util/path.go
@@ -12,7 +12,6 @@ import (
 	"path/filepath"
 	"regexp"
 	"runtime"
-	"strings"
 )
 
 // EnsureAbsolutePath ensure that a path is absolute, making it
@@ -91,7 +90,7 @@ func statDir(dirPath, recPath string, includeDir, isDirOnly, followSymlinks bool
 
 	statList := make([]string, 0)
 	for _, fi := range fis {
-		if strings.Contains(fi.Name(), ".DS_Store") {
+		if CommonSkip(fi.Name()) {
 			continue
 		}
 
@@ -199,3 +198,21 @@ func HomeDir() (home string, err error) {
 
 	return home, nil
 }
+
+// CommonSkip will check a provided name to see if it represents file or directory that should not be watched
+func CommonSkip(name string) bool {
+	if name == "" {
+		return true
+	}
+
+	switch name[0] {
+	case '.':
+		return true
+	case 't', 'T':
+		return name[1:] == "humbs.db"
+	case 'd', 'D':
+		return name[1:] == "esktop.ini"
+	}
+
+	return false
+}
diff --git a/modules/watcher/watcher.go b/modules/watcher/watcher.go
new file mode 100644
index 000000000000..d737f6ccbbca
--- /dev/null
+++ b/modules/watcher/watcher.go
@@ -0,0 +1,115 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package watcher
+
+import (
+	"context"
+	"io/fs"
+	"os"
+
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/process"
+
+	"github.com/fsnotify/fsnotify"
+)
+
+// CreateWatcherOpts are options to configure the watcher
+type CreateWatcherOpts struct {
+	// PathsCallback is used to set the required paths to watch
+	PathsCallback func(func(path, name string, d fs.DirEntry, err error) error) error
+
+	// BeforeCallback is called before any files are watched
+	BeforeCallback func()
+
+	// Between Callback is called between after a watched event has occurred
+	BetweenCallback func()
+
+	// AfterCallback is called as this watcher ends
+	AfterCallback func()
+}
+
+// CreateWatcher creates a watcher labelled with the provided description and running with the provided options.
+// The created watcher will create a subcontext from the provided ctx and register it with the process manager.
+func CreateWatcher(ctx context.Context, desc string, opts *CreateWatcherOpts) {
+	go run(ctx, desc, opts)
+}
+
+func run(ctx context.Context, desc string, opts *CreateWatcherOpts) {
+	if opts.BeforeCallback != nil {
+		opts.BeforeCallback()
+	}
+	if opts.AfterCallback != nil {
+		defer opts.AfterCallback()
+	}
+	ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Watcher: "+desc, process.SystemProcessType, true)
+	defer finished()
+
+	log.Trace("Watcher loop starting for %s", desc)
+	defer log.Trace("Watcher loop ended for %s", desc)
+
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		log.Error("Unable to create watcher for %s: %v", desc, err)
+		return
+	}
+	if err := opts.PathsCallback(func(path, _ string, d fs.DirEntry, err error) error {
+		if err != nil && !os.IsNotExist(err) {
+			return err
+		}
+		log.Trace("Watcher: %s watching %q", desc, path)
+		_ = watcher.Add(path)
+		return nil
+	}); err != nil {
+		log.Error("Unable to create watcher for %s: %v", desc, err)
+		_ = watcher.Close()
+		return
+	}
+
+	// Note we don't call the BetweenCallback here
+
+	for {
+		select {
+		case event, ok := <-watcher.Events:
+			if !ok {
+				_ = watcher.Close()
+				return
+			}
+			log.Debug("Watched file for %s had event: %v", desc, event)
+		case err, ok := <-watcher.Errors:
+			if !ok {
+				_ = watcher.Close()
+				return
+			}
+			log.Error("Error whilst watching files for %s: %v", desc, err)
+		case <-ctx.Done():
+			_ = watcher.Close()
+			return
+		}
+
+		// Recreate the watcher - only call the BetweenCallback after the new watcher is set-up
+		_ = watcher.Close()
+		watcher, err = fsnotify.NewWatcher()
+		if err != nil {
+			log.Error("Unable to create watcher for %s: %v", desc, err)
+			return
+		}
+		if err := opts.PathsCallback(func(path, _ string, _ fs.DirEntry, err error) error {
+			if err != nil {
+				return err
+			}
+			_ = watcher.Add(path)
+			return nil
+		}); err != nil {
+			log.Error("Unable to create watcher for %s: %v", desc, err)
+			_ = watcher.Close()
+			return
+		}
+
+		// Inform our BetweenCallback that there has been an event
+		if opts.BetweenCallback != nil {
+			opts.BetweenCallback()
+		}
+	}
+}
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
index cbf041a7e136..0761cacdf2e2 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -5,6 +5,7 @@
 package packages
 
 import (
+	gocontext "context"
 	"net/http"
 	"regexp"
 	"strings"
@@ -38,10 +39,10 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
 	}
 }
 
-func Routes() *web.Route {
+func Routes(ctx gocontext.Context) *web.Route {
 	r := web.NewRoute()
 
-	r.Use(context.PackageContexter())
+	r.Use(context.PackageContexter(ctx))
 
 	authMethods := []auth.Method{
 		&auth.OAuth2{},
@@ -270,10 +271,10 @@ func Routes() *web.Route {
 	return r
 }
 
-func ContainerRoutes() *web.Route {
+func ContainerRoutes(ctx gocontext.Context) *web.Route {
 	r := web.NewRoute()
 
-	r.Use(context.PackageContexter())
+	r.Use(context.PackageContexter(ctx))
 
 	authMethods := []auth.Method{
 		&auth.Basic{},
diff --git a/routers/api/packages/pypi/pypi.go b/routers/api/packages/pypi/pypi.go
index 390907450479..07cf766c61a6 100644
--- a/routers/api/packages/pypi/pypi.go
+++ b/routers/api/packages/pypi/pypi.go
@@ -16,7 +16,6 @@ import (
 	packages_module "code.gitea.io/gitea/modules/packages"
 	pypi_module "code.gitea.io/gitea/modules/packages/pypi"
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/validation"
 	"code.gitea.io/gitea/routers/api/packages/helper"
 	packages_service "code.gitea.io/gitea/services/packages"
@@ -58,7 +57,6 @@ func PackageMetadata(ctx *context.Context) {
 	ctx.Data["RegistryURL"] = setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pypi"
 	ctx.Data["PackageDescriptor"] = pds[0]
 	ctx.Data["PackageDescriptors"] = pds
-	ctx.Render = templates.HTMLRenderer()
 	ctx.HTML(http.StatusOK, "api/packages/pypi/simple")
 }
 
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index e1478fa2aa99..b413370ad260 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -65,6 +65,7 @@
 package v1
 
 import (
+	gocontext "context"
 	"fmt"
 	"net/http"
 	"reflect"
@@ -605,7 +606,7 @@ func buildAuthGroup() *auth.Group {
 }
 
 // Routes registers all v1 APIs routes to web application.
-func Routes() *web.Route {
+func Routes(ctx gocontext.Context) *web.Route {
 	m := web.NewRoute()
 
 	m.Use(securityHeaders())
@@ -623,7 +624,7 @@ func Routes() *web.Route {
 	m.Use(context.APIContexter())
 
 	group := buildAuthGroup()
-	if err := group.Init(); err != nil {
+	if err := group.Init(ctx); err != nil {
 		log.Error("Could not initialize '%s' auth method, error: %s", group.Name(), err)
 	}
 
diff --git a/routers/api/v1/misc/markdown_test.go b/routers/api/v1/misc/markdown_test.go
index 9beb88be1684..7809fa5cc72a 100644
--- a/routers/api/v1/misc/markdown_test.go
+++ b/routers/api/v1/misc/markdown_test.go
@@ -29,7 +29,7 @@ const (
 )
 
 func createContext(req *http.Request) (*context.Context, *httptest.ResponseRecorder) {
-	rnd := templates.HTMLRenderer()
+	_, rnd := templates.HTMLRenderer(req.Context())
 	resp := httptest.NewRecorder()
 	c := &context.Context{
 		Req:    req,
diff --git a/routers/init.go b/routers/init.go
index 612fc5a83dbb..85a38899e34f 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -27,6 +27,7 @@ import (
 	"code.gitea.io/gitea/modules/ssh"
 	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/svg"
+	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
@@ -110,12 +111,12 @@ func GlobalInitInstalled(ctx context.Context) {
 	log.Info("Run Mode: %s", util.ToTitleCase(setting.RunMode))
 
 	// Setup i18n
-	translation.InitLocales()
+	translation.InitLocales(ctx)
 
 	setting.NewServices()
 	mustInit(storage.Init)
 
-	mailer.NewContext()
+	mailer.NewContext(ctx)
 	mustInit(cache.NewContext)
 	notification.NewContext()
 	mustInit(archiver.Init)
@@ -163,18 +164,19 @@ func GlobalInitInstalled(ctx context.Context) {
 }
 
 // NormalRoutes represents non install routes
-func NormalRoutes() *web.Route {
+func NormalRoutes(ctx context.Context) *web.Route {
+	ctx, _ = templates.HTMLRenderer(ctx)
 	r := web.NewRoute()
 	for _, middle := range common.Middlewares() {
 		r.Use(middle)
 	}
 
-	r.Mount("/", web_routers.Routes())
-	r.Mount("/api/v1", apiv1.Routes())
+	r.Mount("/", web_routers.Routes(ctx))
+	r.Mount("/api/v1", apiv1.Routes(ctx))
 	r.Mount("/api/internal", private.Routes())
 	if setting.Packages.Enabled {
-		r.Mount("/api/packages", packages_router.Routes())
-		r.Mount("/v2", packages_router.ContainerRoutes())
+		r.Mount("/api/packages", packages_router.Routes(ctx))
+		r.Mount("/v2", packages_router.ContainerRoutes(ctx))
 	}
 	return r
 }
diff --git a/routers/install/install.go b/routers/install/install.go
index 8060414a1115..890725b9a747 100644
--- a/routers/install/install.go
+++ b/routers/install/install.go
@@ -6,6 +6,7 @@
 package install
 
 import (
+	goctx "context"
 	"fmt"
 	"net/http"
 	"os"
@@ -51,39 +52,41 @@ func getSupportedDbTypeNames() (dbTypeNames []map[string]string) {
 }
 
 // Init prepare for rendering installation page
-func Init(next http.Handler) http.Handler {
-	rnd := templates.HTMLRenderer()
+func Init(ctx goctx.Context) func(next http.Handler) http.Handler {
+	_, rnd := templates.HTMLRenderer(ctx)
 	dbTypeNames := getSupportedDbTypeNames()
-	return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
-		if setting.InstallLock {
-			resp.Header().Add("Refresh", "1; url="+setting.AppURL+"user/login")
-			_ = rnd.HTML(resp, http.StatusOK, string(tplPostInstall), nil)
-			return
-		}
-		locale := middleware.Locale(resp, req)
-		startTime := time.Now()
-		ctx := context.Context{
-			Resp:    context.NewResponse(resp),
-			Flash:   &middleware.Flash{},
-			Locale:  locale,
-			Render:  rnd,
-			Session: session.GetSession(req),
-			Data: map[string]interface{}{
-				"locale":        locale,
-				"Title":         locale.Tr("install.install"),
-				"PageIsInstall": true,
-				"DbTypeNames":   dbTypeNames,
-				"AllLangs":      translation.AllLangs(),
-				"PageStartTime": startTime,
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
+			if setting.InstallLock {
+				resp.Header().Add("Refresh", "1; url="+setting.AppURL+"user/login")
+				_ = rnd.HTML(resp, http.StatusOK, string(tplPostInstall), nil)
+				return
+			}
+			locale := middleware.Locale(resp, req)
+			startTime := time.Now()
+			ctx := context.Context{
+				Resp:    context.NewResponse(resp),
+				Flash:   &middleware.Flash{},
+				Locale:  locale,
+				Render:  rnd,
+				Session: session.GetSession(req),
+				Data: map[string]interface{}{
+					"locale":        locale,
+					"Title":         locale.Tr("install.install"),
+					"PageIsInstall": true,
+					"DbTypeNames":   dbTypeNames,
+					"AllLangs":      translation.AllLangs(),
+					"PageStartTime": startTime,
 
-				"PasswordHashAlgorithms": user_model.AvailableHashAlgorithms,
-			},
-		}
-		defer ctx.Close()
+					"PasswordHashAlgorithms": user_model.AvailableHashAlgorithms,
+				},
+			}
+			defer ctx.Close()
 
-		ctx.Req = context.WithContext(req, &ctx)
-		next.ServeHTTP(resp, ctx.Req)
-	})
+			ctx.Req = context.WithContext(req, &ctx)
+			next.ServeHTTP(resp, ctx.Req)
+		})
+	}
 }
 
 // Install render installation page
diff --git a/routers/install/routes.go b/routers/install/routes.go
index fdabcb9dc22c..7617477827cb 100644
--- a/routers/install/routes.go
+++ b/routers/install/routes.go
@@ -5,6 +5,7 @@
 package install
 
 import (
+	goctx "context"
 	"fmt"
 	"net/http"
 	"path"
@@ -29,8 +30,8 @@ func (d *dataStore) GetData() map[string]interface{} {
 	return *d
 }
 
-func installRecovery() func(next http.Handler) http.Handler {
-	rnd := templates.HTMLRenderer()
+func installRecovery(ctx goctx.Context) func(next http.Handler) http.Handler {
+	_, rnd := templates.HTMLRenderer(ctx)
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 			defer func() {
@@ -82,7 +83,7 @@ func installRecovery() func(next http.Handler) http.Handler {
 }
 
 // Routes registers the install routes
-func Routes() *web.Route {
+func Routes(ctx goctx.Context) *web.Route {
 	r := web.NewRoute()
 	for _, middle := range common.Middlewares() {
 		r.Use(middle)
@@ -105,8 +106,8 @@ func Routes() *web.Route {
 		Domain:         setting.SessionConfig.Domain,
 	}))
 
-	r.Use(installRecovery())
-	r.Use(Init)
+	r.Use(installRecovery(ctx))
+	r.Use(Init(ctx))
 	r.Get("/", Install)
 	r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall)
 	r.Get("/api/healthz", healthcheck.Check)
diff --git a/routers/install/routes_test.go b/routers/install/routes_test.go
index 29003c3841be..e69d2d15dfaf 100644
--- a/routers/install/routes_test.go
+++ b/routers/install/routes_test.go
@@ -5,13 +5,16 @@
 package install
 
 import (
+	"context"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 )
 
 func TestRoutes(t *testing.T) {
-	routes := Routes()
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	routes := Routes(ctx)
 	assert.NotNil(t, routes)
 	assert.EqualValues(t, "/", routes.R.Routes()[0].Pattern)
 	assert.Nil(t, routes.R.Routes()[0].SubRoutes)
diff --git a/routers/install/setting.go b/routers/install/setting.go
index cf0a01ce31f5..c4912f1124f8 100644
--- a/routers/install/setting.go
+++ b/routers/install/setting.go
@@ -24,7 +24,7 @@ func PreloadSettings(ctx context.Context) bool {
 		log.Info("Log path: %s", setting.LogRootPath)
 		log.Info("Configuration file: %s", setting.CustomConf)
 		log.Info("Prepare to run install page")
-		translation.InitLocales()
+		translation.InitLocales(ctx)
 		if setting.EnableSQLite3 {
 			log.Info("SQLite3 is supported")
 		}
diff --git a/routers/web/base.go b/routers/web/base.go
index 30a24a127543..2441d6d51716 100644
--- a/routers/web/base.go
+++ b/routers/web/base.go
@@ -5,6 +5,7 @@
 package web
 
 import (
+	goctx "context"
 	"errors"
 	"fmt"
 	"io"
@@ -123,8 +124,8 @@ func (d *dataStore) GetData() map[string]interface{} {
 
 // Recovery returns a middleware that recovers from any panics and writes a 500 and a log if so.
 // This error will be created with the gitea 500 page.
-func Recovery() func(next http.Handler) http.Handler {
-	rnd := templates.HTMLRenderer()
+func Recovery(ctx goctx.Context) func(next http.Handler) http.Handler {
+	_, rnd := templates.HTMLRenderer(ctx)
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 			defer func() {
diff --git a/routers/web/web.go b/routers/web/web.go
index 55bce1611747..1852ecc2e24f 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -22,6 +22,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/validation"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/modules/web/routing"
@@ -97,7 +98,7 @@ func buildAuthGroup() *auth_service.Group {
 }
 
 // Routes returns all web routes
-func Routes() *web.Route {
+func Routes(ctx gocontext.Context) *web.Route {
 	routes := web.NewRoute()
 
 	routes.Use(web.WrapWithPrefix(public.AssetsURLPathPrefix, public.AssetsHandlerFunc(&public.Options{
@@ -119,7 +120,9 @@ func Routes() *web.Route {
 	})
 	routes.Use(sessioner)
 
-	routes.Use(Recovery())
+	ctx, _ = templates.HTMLRenderer(ctx)
+
+	routes.Use(Recovery(ctx))
 
 	// We use r.Route here over r.Use because this prevents requests that are not for avatars having to go through this additional handler
 	routes.Route("/avatars/*", "GET, HEAD", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
@@ -192,10 +195,10 @@ func Routes() *web.Route {
 	routes.Get("/api/healthz", healthcheck.Check)
 
 	// Removed: toolbox.Toolboxer middleware will provide debug information which seems unnecessary
-	common = append(common, context.Contexter())
+	common = append(common, context.Contexter(ctx))
 
 	group := buildAuthGroup()
-	if err := group.Init(); err != nil {
+	if err := group.Init(ctx); err != nil {
 		log.Error("Could not initialize '%s' auth method, error: %s", group.Name(), err)
 	}
 
diff --git a/services/auth/group.go b/services/auth/group.go
index 0f40e1a76c9b..bbafe64b495c 100644
--- a/services/auth/group.go
+++ b/services/auth/group.go
@@ -5,6 +5,7 @@
 package auth
 
 import (
+	"context"
 	"net/http"
 	"reflect"
 	"strings"
@@ -51,14 +52,14 @@ func (b *Group) Name() string {
 }
 
 // Init does nothing as the Basic implementation does not need to allocate any resources
-func (b *Group) Init() error {
+func (b *Group) Init(ctx context.Context) error {
 	for _, method := range b.methods {
 		initializable, ok := method.(Initializable)
 		if !ok {
 			continue
 		}
 
-		if err := initializable.Init(); err != nil {
+		if err := initializable.Init(ctx); err != nil {
 			return err
 		}
 	}
diff --git a/services/auth/interface.go b/services/auth/interface.go
index a05ece2078d1..ecc9ad2ca6b8 100644
--- a/services/auth/interface.go
+++ b/services/auth/interface.go
@@ -34,7 +34,7 @@ type Method interface {
 type Initializable interface {
 	// Init should be called exactly once before using any of the other methods,
 	// in order to allow the plugin to allocate necessary resources
-	Init() error
+	Init(ctx context.Context) error
 }
 
 // Named represents a named thing
diff --git a/services/auth/sspi_windows.go b/services/auth/sspi_windows.go
index 7e31378b6c4d..757d596c4c21 100644
--- a/services/auth/sspi_windows.go
+++ b/services/auth/sspi_windows.go
@@ -5,6 +5,7 @@
 package auth
 
 import (
+	"context"
 	"errors"
 	"net/http"
 	"strings"
@@ -52,21 +53,14 @@ type SSPI struct {
 }
 
 // Init creates a new global websspi.Authenticator object
-func (s *SSPI) Init() error {
+func (s *SSPI) Init(ctx context.Context) error {
 	config := websspi.NewConfig()
 	var err error
 	sspiAuth, err = websspi.New(config)
 	if err != nil {
 		return err
 	}
-	s.rnd = render.New(render.Options{
-		Extensions:    []string{".tmpl"},
-		Directory:     "templates",
-		Funcs:         templates.NewFuncMap(),
-		Asset:         templates.GetAsset,
-		AssetNames:    templates.GetAssetNames,
-		IsDevelopment: !setting.IsProd,
-	})
+	_, s.rnd = templates.HTMLRenderer(ctx)
 	return nil
 }
 
diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go
index fdbb6e562bfc..1f43c7f82792 100644
--- a/services/mailer/mailer.go
+++ b/services/mailer/mailer.go
@@ -7,6 +7,7 @@ package mailer
 
 import (
 	"bytes"
+	"context"
 	"crypto/tls"
 	"fmt"
 	"hash/fnv"
@@ -348,7 +349,7 @@ var mailQueue queue.Queue
 var Sender gomail.Sender
 
 // NewContext start mail queue service
-func NewContext() {
+func NewContext(ctx context.Context) {
 	// Need to check if mailQueue is nil because in during reinstall (user had installed
 	// before but switched install lock off), this function will be called again
 	// while mail queue is already processing tasks, and produces a race condition.
@@ -381,7 +382,7 @@ func NewContext() {
 
 	go graceful.GetManager().RunWithShutdownFns(mailQueue.Run)
 
-	subjectTemplates, bodyTemplates = templates.Mailer()
+	subjectTemplates, bodyTemplates = templates.Mailer(ctx)
 }
 
 // SendAsync send mail asynchronously