diff --git a/cmd/program.go b/cmd/program.go
index 18f8a09aa90315ddc53b60364b9f9fe434427928_Y21kL3Byb2dyYW0uZ28=..5a251df9bb98f7eb2ca1587c14f4b2480718a943_Y21kL3Byb2dyYW0uZ28= 100644
--- a/cmd/program.go
+++ b/cmd/program.go
@@ -38,6 +38,7 @@
 }
 
 type Program struct {
+	Version         Version
 	BootstrapParser *flags.Parser
 	Parser          *flags.Parser
 
@@ -58,7 +59,9 @@
 	hasDB           bool
 	dbMigrateSource source.Driver
 
+	middlewares []Middleware
+
 	setupXbusActors  func(*Program) []XbusActorFactory
 	xbusActorNames   []string
 	setupHandler     func(*Program) http.Handler
 	setupSubcommands []subcommand
@@ -61,7 +64,9 @@
 	setupXbusActors  func(*Program) []XbusActorFactory
 	xbusActorNames   []string
 	setupHandler     func(*Program) http.Handler
 	setupSubcommands []subcommand
+
+	postInit []func(*Program)
 }
 
 type Option func(program *Program)
@@ -72,6 +77,38 @@
 	}
 }
 
+type Middleware interface {
+	Middleware(http.Handler) (http.Handler, error)
+}
+
+type middlewareFunc func(http.Handler) (http.Handler, error)
+
+func (f middlewareFunc) Middleware(next http.Handler) (http.Handler, error) {
+	return f(next)
+}
+
+type middlewareFuncNoErr func(http.Handler) http.Handler
+
+func (f middlewareFuncNoErr) Middleware(next http.Handler) (http.Handler, error) {
+	return f(next), nil
+}
+
+func WithMiddleware(middleware any) Option {
+	var m Middleware
+	switch f := middleware.(type) {
+	case func(http.Handler) http.Handler:
+		m = middlewareFuncNoErr(f)
+	case func(http.Handler) (http.Handler, error):
+		m = middlewareFunc(f)
+	default:
+		panic(fmt.Errorf("Invalid middleware type: %t", middleware))
+	}
+
+	return func(program *Program) {
+		program.middlewares = append(program.middlewares, m)
+	}
+}
+
 func WithXbusActors(factories func(*Program) []XbusActorFactory) Option {
 	return func(program *Program) {
 		program.setupXbusActors = factories
@@ -103,8 +140,14 @@
 	}
 }
 
-func NewProgram(name string, options ...Option) *Program {
+type Version struct {
+	Version string
+	Hash    string
+	Build   string
+}
+
+func NewProgram(name string, version Version, options ...Option) *Program {
 	bootstrapParser := flags.NewNamedParser(name, flags.IgnoreUnknown)
 	parser := flags.NewNamedParser(name, flags.HelpFlag|flags.PassDoubleDash)
 
 	program := Program{
@@ -107,7 +150,8 @@
 	bootstrapParser := flags.NewNamedParser(name, flags.IgnoreUnknown)
 	parser := flags.NewNamedParser(name, flags.HelpFlag|flags.PassDoubleDash)
 
 	program := Program{
+		Version:         version,
 		BootstrapParser: bootstrapParser,
 		Parser:          parser,
 		Logger:          orusapi.DefaultLogger(os.Stdout),
@@ -148,6 +192,7 @@
 
 	program.ServeCmd = SetupServeCmd(&program)
 	program.GenerateConfigCmd = SetupGenerateConfigCmd(&program)
+	_ = SetupVersionCmd(&program)
 
 	if program.hasDB {
 		g, err := parser.AddGroup("Database", "Database options", &program.DatabaseOptions)
@@ -166,6 +211,10 @@
 		}
 	}
 
+	for _, pi := range program.postInit {
+		pi(&program)
+	}
+
 	return &program
 }
 
@@ -169,6 +218,12 @@
 	return &program
 }
 
+func PostInit(pi func(program *Program)) func(program *Program) {
+	return func(program *Program) {
+		program.postInit = append(program.postInit, pi)
+	}
+}
+
 func (program *Program) ParseArgs(args []string) int {
 	if _, err := program.BootstrapParser.ParseArgs(args); err != nil {
 		program.Logger.Err(err).Msg("could not parse command line")
diff --git a/cmd/program_ui.go b/cmd/program_ui.go
new file mode 100644
index 0000000000000000000000000000000000000000..5a251df9bb98f7eb2ca1587c14f4b2480718a943_Y21kL3Byb2dyYW1fdWkuZ28=
--- /dev/null
+++ b/cmd/program_ui.go
@@ -0,0 +1,137 @@
+package cmd
+
+import (
+	"io/fs"
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"strings"
+
+	"github.com/timewasted/go-accept-headers"
+
+	"orus.io/orus-io/go-orusapi"
+)
+
+type UIOptions struct {
+	fs    fs.FS
+	paths []string
+
+	External string `long:"ui-external" ini-name:"ui-ui-external" description:"UI external server"`
+}
+
+func NewIgnoreNotFoundResponseWriter(rw http.ResponseWriter) *IgnoreNotFoundResponseWriter {
+	return &IgnoreNotFoundResponseWriter{
+		header: rw.Header().Clone(),
+	}
+}
+
+type IgnoreNotFoundResponseWriter struct {
+	header   http.Header
+	notfound *bool
+	next     http.ResponseWriter
+}
+
+func (rw *IgnoreNotFoundResponseWriter) NotFound() bool {
+	return rw.notfound != nil && *rw.notfound
+}
+
+func (rw *IgnoreNotFoundResponseWriter) Header() http.Header {
+	return rw.header
+}
+
+func (rw *IgnoreNotFoundResponseWriter) flushHeader() {
+	nh := rw.next.Header()
+	for k := range nh {
+		if _, ok := rw.header[k]; !ok {
+			nh.Del(k)
+		}
+	}
+	for k, v := range rw.header {
+		nh[k] = v
+	}
+}
+
+func (rw *IgnoreNotFoundResponseWriter) WriteHeader(statusCode int) {
+	notFound := statusCode == http.StatusNotFound
+	rw.notfound = &notFound
+	if !notFound {
+		rw.flushHeader()
+		rw.next.WriteHeader(statusCode)
+	}
+}
+
+func (rw *IgnoreNotFoundResponseWriter) Write(data []byte) (int, error) {
+	if rw.notfound == nil {
+		var value bool
+		rw.notfound = &value
+		rw.flushHeader()
+	}
+	if *rw.notfound {
+		return 0, nil
+	}
+
+	return rw.next.Write(data)
+}
+
+func WithUI(uifs fs.FS, prefix string) Option {
+	return PostInit(func(program *Program) {
+		uiOptions := UIOptions{
+			fs: uifs,
+		}
+		var serveFound bool
+		for _, cmd := range program.Parser.Commands() {
+			if cmd.Name == "serve" {
+				_, err := cmd.AddGroup("ui", "User Interface", &uiOptions)
+				if err != nil {
+					panic(err)
+				}
+				serveFound = true
+
+				break
+			}
+		}
+		if !serveFound {
+			panic("serve command not found")
+		}
+		middleware := func(next http.Handler) (http.Handler, error) {
+			var uiHandler http.Handler
+			if uiOptions.External == "" {
+				uiHandler = orusapi.NewSPAFileServer(
+					http.FS(uiOptions.fs),
+					prefix,
+				)
+			} else {
+				u, err := url.Parse(uiOptions.External)
+				if err != nil {
+					return nil, err
+				}
+				uiHandler = httputil.NewSingleHostReverseProxy(u)
+			}
+			if prefix != "" {
+				uiHandler = http.StripPrefix(prefix, uiHandler)
+			}
+
+			return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+				if prefix != "" {
+					if !strings.HasPrefix(r.URL.Path, prefix) {
+						next.ServeHTTP(rw, r)
+					}
+				}
+				accepted, err := accept.Negotiate(
+					r.Header.Get("Accept"),
+					"text/html", "application/json")
+				if err != nil || accepted == "application/json" {
+					next.ServeHTTP(rw, r)
+				} else {
+					rwWrapper := NewIgnoreNotFoundResponseWriter(rw)
+					uiHandler.ServeHTTP(rwWrapper, r)
+
+					if rwWrapper.NotFound() {
+						next.ServeHTTP(rw, r)
+					}
+				}
+			}), nil
+		}
+		WithMiddleware(middleware)(program)
+	})
+}
diff --git a/cmd/version.go b/cmd/version.go
new file mode 100644
index 0000000000000000000000000000000000000000..5a251df9bb98f7eb2ca1587c14f4b2480718a943_Y21kL3ZlcnNpb24uZ28=
--- /dev/null
+++ b/cmd/version.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+	"fmt"
+)
+
+func SetupVersionCmd(program *Program) *VersionCmd {
+	cmd := VersionCmd{
+		program: program,
+	}
+
+	if _, err := program.Parser.AddCommand(
+		"version", "Show the program version", "", &cmd,
+	); err != nil {
+		program.Logger.Fatal().Msg(err.Error())
+	}
+
+	return &cmd
+}
+
+type VersionCmd struct {
+	program *Program
+
+	Verbose bool `short:"v" long:"verbose" description:"display the source hash and build number if available"`
+}
+
+func (cmd *VersionCmd) Execute([]string) error {
+
+	versionLine := cmd.program.Version.Version
+	if cmd.Verbose {
+		if cmd.program.Version.Hash != "" {
+			versionLine += "\nHash:" + cmd.program.Version.Hash
+		}
+		if cmd.program.Version.Build != "" {
+			versionLine += "\nBuild:" + cmd.program.Version.Build
+		}
+	}
+	if _, err := fmt.Println(versionLine); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/go.mod b/go.mod
index 18f8a09aa90315ddc53b60364b9f9fe434427928_Z28ubW9k..5a251df9bb98f7eb2ca1587c14f4b2480718a943_Z28ubW9k 100644
--- a/go.mod
+++ b/go.mod
@@ -66,6 +66,7 @@
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/spf13/viper v1.16.0 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
+	github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 // indirect
 	go.uber.org/atomic v1.11.0 // indirect
 	golang.org/x/crypto v0.13.0 // indirect
 	golang.org/x/sys v0.21.0 // indirect
diff --git a/go.sum b/go.sum
index 18f8a09aa90315ddc53b60364b9f9fe434427928_Z28uc3Vt..5a251df9bb98f7eb2ca1587c14f4b2480718a943_Z28uc3Vt 100644
--- a/go.sum
+++ b/go.sum
@@ -450,6 +450,8 @@
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 h1:QVxbx5l/0pzciWYOynixQMtUhPYC3YKD6EcUlOsgGqw=
+github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09/go.mod h1:Uy/Rnv5WKuOO+PuDhuYLEpUiiKIZtss3z519uk67aF0=
 github.com/xhit/go-str2duration v1.2.0/go.mod h1:3cPSlfZlUHVlneIVfePFWcJZsuwf+P1v2SRTV4cUmp4=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
diff --git a/spa_fileserver.go b/spa_fileserver.go
new file mode 100644
index 0000000000000000000000000000000000000000..5a251df9bb98f7eb2ca1587c14f4b2480718a943_c3BhX2ZpbGVzZXJ2ZXIuZ28=
--- /dev/null
+++ b/spa_fileserver.go
@@ -0,0 +1,209 @@
+package orusapi
+
+import (
+	"mime"
+	"net/http"
+	"os"
+	"path"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+func NewSPAFileServer(root http.FileSystem, version string) http.Handler {
+	return &SPAFileHandler{root, version}
+}
+
+type SPAFileHandler struct {
+	root http.FileSystem
+	etag string
+}
+
+var encodingWeight = map[string]int{
+	"deflate": 1,
+	"gzip":    2,
+	"br":      3,
+}
+
+func acceptEncoding(r *http.Request) []string {
+	s := r.Header.Get("Accept-Encoding")
+	splitted := strings.Split(s, ",")
+	for i := range splitted {
+		splitted[i] = strings.Trim(splitted[i], " ")
+	}
+	sort.Slice(splitted, func(i, j int) bool {
+		return encodingWeight[splitted[i]] > encodingWeight[splitted[j]]
+	})
+
+	return splitted
+}
+
+func encoding(encoding string) (string, string) {
+	switch encoding {
+	case "br":
+		return "br", ".br"
+	case "gzip":
+		return "gzip", ".gz"
+	case "deflate":
+		return "", ""
+	default:
+		return "", ""
+	}
+}
+
+func (h *SPAFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	upath := r.URL.Path
+	if !strings.HasPrefix(upath, "/") {
+		upath = "/" + upath
+		r.URL.Path = upath
+	}
+	_, debugMode := r.URL.Query()["debug"]
+
+	h.serveFile(w, r, path.Clean(upath), true, debugMode)
+}
+
+// localRedirect gives a Moved Permanently response.
+// It does not convert relative paths to absolute paths like Redirect does.
+func (h *SPAFileHandler) localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
+	if q := r.URL.RawQuery; q != "" {
+		newPath += "?" + q
+	}
+	w.Header().Set("Location", newPath)
+	w.WriteHeader(http.StatusMovedPermanently)
+}
+
+func (h *SPAFileHandler) open(name string, acceptEncoding []string) (http.File, string, error) {
+	for _, enc := range acceptEncoding {
+		contentEncoding, ext := encoding(enc)
+		f, err := h.root.Open(name + ext)
+		if err == nil {
+			return f, contentEncoding, nil
+		}
+		if !os.IsNotExist(err) {
+			return nil, "", err
+		}
+	}
+
+	return nil, "", os.ErrNotExist
+}
+
+func (h *SPAFileHandler) serveFile(w http.ResponseWriter, r *http.Request, name string, redirect bool, debugMode bool) {
+	ifNoneMatch := r.Header.Get("If-None-Match")
+	if ifNoneMatch == h.etag {
+		w.WriteHeader(http.StatusNotModified)
+
+		return
+	}
+	indexPage := "/index.html"
+	if debugMode {
+		indexPage = "/index.debug.html"
+	}
+
+	if strings.HasPrefix(r.URL.Path, "/payment/") {
+		indexPage = "/payment.html"
+		if debugMode {
+			indexPage = "/payment.debug.html"
+		}
+	}
+
+	// redirect .../index.html to .../
+	// can't use Redirect() because that would make the path absolute,
+	// which would be a problem running under StripPrefix
+	if strings.HasSuffix(r.URL.Path, indexPage) {
+		h.localRedirect(w, r, "./")
+
+		return
+	}
+
+	f, contentEncoding, err := h.open(name, acceptEncoding(r))
+	if os.IsNotExist(err) {
+		f, contentEncoding, err = h.open(indexPage, acceptEncoding(r))
+		if err != nil {
+			name = indexPage
+		}
+	}
+	if err != nil {
+		msg, code := toHTTPError(err)
+		http.Error(w, msg, code)
+
+		return
+	}
+	defer f.Close()
+
+	d, err := f.Stat()
+	if err != nil {
+		msg, code := toHTTPError(err)
+		http.Error(w, msg, code)
+
+		return
+	}
+
+	if redirect {
+		// redirect to canonical path: / at end of directory url
+		// r.URL.Path always begins with /
+		url := r.URL.Path
+		if d.IsDir() {
+			if url[len(url)-1] != '/' {
+				h.localRedirect(w, r, path.Base(url)+"/")
+
+				return
+			}
+		} else {
+			if url[len(url)-1] == '/' {
+				h.localRedirect(w, r, "../"+path.Base(url))
+
+				return
+			}
+		}
+	}
+	if d.IsDir() {
+		url := r.URL.Path
+		// redirect if the directory name doesn't end in a slash
+		if url == "" || url[len(url)-1] != '/' {
+			h.localRedirect(w, r, path.Base(url)+"/")
+
+			return
+		}
+
+		// use contents of index.html for directory, if present
+		index := strings.TrimSuffix(name, "/") + indexPage
+		ff, ffContentEncoding, err := h.open(index, acceptEncoding(r))
+		if err == nil {
+			defer ff.Close()
+			dd, err := ff.Stat()
+			if err == nil {
+				name = index
+				d = dd
+				f = ff
+				contentEncoding = ffContentEncoding
+			}
+		}
+	}
+	// Still a directory? (we didn't find an index.html file)
+	if d.IsDir() {
+		http.Error(w, "Not found", http.StatusNotFound)
+
+		return
+	}
+
+	w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(name)))
+	if contentEncoding != "" {
+		w.Header().Set("Content-Encoding", contentEncoding)
+	}
+	w.Header().Set("ETag", h.etag)
+	w.Header().Set("Cache-Control", "no-cache")
+	http.ServeContent(w, r, d.Name(), d.ModTime(), f)
+}
+
+// The following code is copied from the golang 'http' package
+
+func toHTTPError(err error) (msg string, httpStatus int) {
+	if os.IsNotExist(err) {
+		return "404 page not found", http.StatusNotFound
+	}
+	if os.IsPermission(err) {
+		return "403 Forbidden", http.StatusForbidden
+	}
+	// Default:
+	return "500 Internal Server Error", http.StatusInternalServerError
+}
diff --git a/tools/build_version_file/main.go b/tools/build_version_file/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..5a251df9bb98f7eb2ca1587c14f4b2480718a943_dG9vbHMvYnVpbGRfdmVyc2lvbl9maWxlL21haW4uZ28=
--- /dev/null
+++ b/tools/build_version_file/main.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"text/template"
+)
+
+func syscmd(name string, arg ...string) string {
+	cmd := exec.Command(name, arg...)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		fmt.Println(string(out))
+		panic(err)
+	}
+	splitted := strings.Split(string(out), "\n")
+	lines := make([]string, 0, len(splitted))
+	for _, l := range splitted {
+		l = strings.TrimSpace(l)
+		if l == "" {
+			continue
+		}
+		lines = append(lines, l)
+	}
+	if len(lines) != 1 {
+		fmt.Println(string(out))
+		panic("Expects a single line")
+	}
+
+	return lines[0]
+}
+
+func getVersionTag(tags string) string {
+	var candidates []string
+	for _, t := range strings.Split(tags, " ") {
+		if strings.HasPrefix(t, "v") {
+			candidates = append(candidates, t)
+		}
+	}
+	if len(candidates) == 0 {
+		return ""
+	}
+	return candidates[0]
+}
+
+var outTemplate = template.Must(template.New("").Parse(`package {{ .package }}
+
+var Version = "{{ .version }}"
+var HgSha = "{{ .sha }}"
+var Build = "{{ .build }}"
+`))
+
+func main() {
+	sha := syscmd("hg", "id", "--id")
+	curVersion := getVersionTag(syscmd("hg", "id", "--tags"))
+	lastVersion := getVersionTag(syscmd("hg", "id", "--tags", "-r", "limit(last(ancestors(.)&tag('re:v.*'))|.,1)"))
+	modified := strings.HasSuffix(sha, "+")
+	jobID := os.Getenv("CI_JOB_ID")
+
+	var version string
+
+	switch {
+	case modified && lastVersion != "":
+		version = lastVersion + ""
+	case curVersion != "":
+		version = curVersion
+	case lastVersion != "":
+		version = lastVersion + "-" + sha
+	default:
+		version = "v0.0.0-" + sha
+	}
+
+	vars := map[string]string{
+		"package": "main",
+		"sha":     sha,
+		"version": version,
+		"build":   jobID,
+	}
+
+	out, err := os.Create("version.go")
+	if err != nil {
+		panic(err)
+	}
+
+	if err := outTemplate.Execute(out, vars); err != nil {
+		panic(err)
+	}
+}