diff --git a/Makefile b/Makefile
index 32d2a129798465dc016b874abb226fb8b5b00156..b9c7e31822e4f2bd4ad134f73e9782565ca0a3c7 100644
--- a/Makefile
+++ b/Makefile
@@ -58,6 +58,7 @@ web-build:
 		&& rm -rf ../server/site \
 		&& mv build ../server/site \
 		&& rm \
+			../server/site/config.js \
 			../server/site/precache* \
 			../server/site/service-worker.js \
 			../server/site/asset-manifest.json \
diff --git a/server/server.go b/server/server.go
index 74caa26128c4566169c5982bc111db3bd99660a6..3aff4e15fe538f03190ea151f4af5e7572cc7b6b 100644
--- a/server/server.go
+++ b/server/server.go
@@ -65,6 +65,7 @@ var (
 	authPathRegex     = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
 	publishPathRegex  = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
 
+	webConfigPath    = "/config.js"
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
 	docsRegex        = regexp.MustCompile(`^/docs(|/.*)$`)
 	fileRegex        = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
@@ -266,6 +267,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.handleExample(w, r)
 	} else if r.Method == http.MethodHead && r.URL.Path == "/" {
 		return s.handleEmpty(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
+		return s.handleWebConfig(w, r)
 	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
 		return s.handleStatic(w, r)
 	} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
@@ -331,6 +334,20 @@ func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error {
 	return err
 }
 
+func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error {
+	appRoot := "/"
+	if !s.config.WebRootIsApp {
+		appRoot = "/app"
+	}
+	disallowedTopicsStr := `"` + strings.Join(disallowedTopics, `", "`) + `"`
+	_, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration
+var config = { 
+  appRoot: "%s",
+  disallowedTopics: [%s]
+};`, appRoot, disallowedTopicsStr))
+	return err
+}
+
 func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
 	r.URL.Path = webSiteDir + r.URL.Path
 	http.FileServer(http.FS(webFsCached)).ServeHTTP(w, r)
diff --git a/web/public/config.js b/web/public/config.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd5fbf053dc52c4e528a6ec23c2ef845b5ceb646
--- /dev/null
+++ b/web/public/config.js
@@ -0,0 +1,9 @@
+// Configuration injected by the ntfy server.
+//
+// This file is just an example. It is removed during the build process.
+// The actual config is dynamically generated server-side.
+
+var config = {
+    appRoot: "/",
+    disallowedTopics: ["docs", "static", "file", "app", "settings"]
+};
diff --git a/web/public/index.html b/web/public/index.html
index 232b0cf932e5721f719908d99f79aec701cfa4a7..93aa4af5111d005dc116d836be73b96ca332ae1d 100644
--- a/web/public/index.html
+++ b/web/public/index.html
@@ -15,13 +15,13 @@
   <meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
 
   <!-- Favicon, see favicon.io -->
-  <link rel="icon" type="image/png" href="static/img/favicon.png">
+  <link rel="icon" type="image/png" href="%PUBLIC_URL%/static/img/favicon.png">
 
   <!-- Previews in Google, Slack, WhatsApp, etc. -->
   <meta property="og:type" content="website" />
   <meta property="og:locale" content="en_US" />
   <meta property="og:site_name" content="ntfy web" />
-  <meta property="og:title" content="ntfy web | Web app to receive push notifications from scripts via PUT/POST" />
+  <meta property="og:title" content="ntfy web" />
   <meta property="og:description" content="ntfy lets you send push notifications via scripts from any computer or phone, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
   <meta property="og:image" content="%PUBLIC_URL%/static/img/ntfy.png" />
   <meta property="og:url" content="https://ntfy.sh" />
@@ -30,10 +30,14 @@
   <meta name="robots" content="noindex, nofollow" />
 
   <!-- Fonts -->
-  <link rel="stylesheet" href="static/css/fonts.css" type="text/css">
+  <link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
 </head>
 <body>
-  <noscript>You need to enable JavaScript to run this app.</noscript>
+  <noscript>
+    ntfy web requires JavaScript, but you can use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a>
+    or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe.
+  </noscript>
   <div id="root"></div>
+  <script src="%PUBLIC_URL%/config.js"></script>
 </body>
 </html>
diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js
index 253acf17b7e5c3cc0ae77f50b4c3f54512805e5a..b1e44498a8fd03e2732d11a73c3dd22324443a12 100644
--- a/web/src/app/SubscriptionManager.js
+++ b/web/src/app/SubscriptionManager.js
@@ -17,12 +17,11 @@ class SubscriptionManager {
         return await db.subscriptions.get(subscriptionId)
     }
 
-    async add(baseUrl, topic, ephemeral) {
+    async add(baseUrl, topic) {
         const subscription = {
             id: topicUrl(baseUrl, topic),
             baseUrl: baseUrl,
             topic: topic,
-            ephemeral: ephemeral,
             mutedUntil: 0,
             last: null
         };
diff --git a/web/src/app/config.js b/web/src/app/config.js
new file mode 100644
index 0000000000000000000000000000000000000000..71a9ece3bb3f805b2cd837dcd71685854f7f90c6
--- /dev/null
+++ b/web/src/app/config.js
@@ -0,0 +1,2 @@
+const config = window.config;
+export default config;
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index 08979fb2f6ac7ec6fa4c9ed2e77ac62e4f817e85..13ff76f8039792a1b7a55f3d00ca4e33793f56d6 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -6,6 +6,7 @@ import ding from "../sounds/ding.mp3";
 import dadum from "../sounds/dadum.mp3";
 import pop from "../sounds/pop.mp3";
 import popSwoosh from "../sounds/pop-swoosh.mp3";
+import config from "./config";
 
 export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
 export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
@@ -25,9 +26,16 @@ export const validUrl = (url) => {
 }
 
 export const validTopic = (topic) => {
+    if (disallowedTopic(topic)) {
+        return false;
+    }
     return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
 }
 
+export const disallowedTopic = (topic) => {
+    return config.disallowedTopics.includes(topic);
+}
+
 // Format emojis (see emoji.js)
 const emojis = {};
 rawEmojis.forEach(emoji => {
@@ -122,13 +130,6 @@ export const openUrl = (url) => {
     window.open(url, "_blank", "noopener,noreferrer");
 };
 
-export const subscriptionRoute = (subscription) => {
-    if (subscription.baseUrl !== window.location.origin) {
-        return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
-    }
-    return `/${subscription.topic}`;
-}
-
 export const sounds = {
     "beep": beep,
     "juntos": juntos,
diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js
index 0dcf6963e27174f9b73932792068574306fef271..1f7ec0e4619a1a3ac145a741bd5569e28f5c9f64 100644
--- a/web/src/components/ActionBar.js
+++ b/web/src/components/ActionBar.js
@@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography";
 import * as React from "react";
 import {useEffect, useRef, useState} from "react";
 import Box from "@mui/material/Box";
-import {subscriptionRoute, topicShortUrl} from "../app/utils";
+import {topicShortUrl} from "../app/utils";
 import {useLocation, useNavigate} from "react-router-dom";
 import ClickAwayListener from '@mui/material/ClickAwayListener';
 import Grow from '@mui/material/Grow';
@@ -19,6 +19,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
 import NotificationsIcon from '@mui/icons-material/Notifications';
 import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
 import api from "../app/Api";
+import routes from "./routes";
 import subscriptionManager from "../app/SubscriptionManager";
 import logo from "../img/ntfy.svg"
 
@@ -98,9 +99,9 @@ const SettingsIcons = (props) => {
         await subscriptionManager.remove(props.subscription.id);
         const newSelected = await subscriptionManager.first(); // May be undefined
         if (newSelected) {
-            navigate(subscriptionRoute(newSelected));
+            navigate(routes.forSubscription(newSelected));
         } else {
-            navigate("/");
+            navigate(routes.root);
         }
     };
 
diff --git a/web/src/components/App.js b/web/src/components/App.js
index 5104d2058f04b15fb8d779f9e5a88a9b61b71cd0..bd3d29e1b37cfac3ca9b4afb2cd01c5c2fb026b7 100644
--- a/web/src/components/App.js
+++ b/web/src/components/App.js
@@ -14,10 +14,16 @@ import Preferences from "./Preferences";
 import {useLiveQuery} from "dexie-react-hooks";
 import subscriptionManager from "../app/SubscriptionManager";
 import userManager from "../app/UserManager";
-import {BrowserRouter, Outlet, Route, Routes, useNavigate, useOutletContext, useParams} from "react-router-dom";
-import {expandSecureUrl, expandUrl, subscriptionRoute, topicUrl} from "../app/utils";
-import poller from "../app/Poller";
+import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom";
+import {expandUrl} from "../app/utils";
+import ErrorBoundary from "./ErrorBoundary";
+import routes from "./routes";
+import {useAutoSubscribe, useConnectionListeners} from "./hooks";
 
+// TODO iPhone blank screen
+// TODO better "send test message" (a la android app)
+// TODO docs
+// TODO screenshot on homepage
 // TODO "copy url" toast
 // TODO "copy link url" button
 // TODO races when two tabs are open
@@ -25,19 +31,21 @@ import poller from "../app/Poller";
 
 const App = () => {
     return (
-        <BrowserRouter>
-            <ThemeProvider theme={theme}>
-                <CssBaseline/>
-                <Routes>
-                    <Route element={<Layout/>}>
-                        <Route path="/" element={<AllSubscriptions/>} />
-                        <Route path="settings" element={<Preferences/>} />
-                        <Route path=":topic" element={<SingleSubscription/>} />
-                        <Route path=":baseUrl/:topic" element={<SingleSubscription/>} />
-                    </Route>
-                </Routes>
-            </ThemeProvider>
-        </BrowserRouter>
+        <ErrorBoundary>
+            <BrowserRouter>
+                <ThemeProvider theme={theme}>
+                    <CssBaseline/>
+                    <Routes>
+                        <Route element={<Layout/>}>
+                            <Route path={routes.root} element={<AllSubscriptions/>} />
+                            <Route path={routes.settings} element={<Preferences/>} />
+                            <Route path={routes.subscription} element={<SingleSubscription/>} />
+                            <Route path={routes.subscriptionExternal} element={<SingleSubscription/>} />
+                        </Route>
+                    </Routes>
+                </ThemeProvider>
+            </BrowserRouter>
+        </ErrorBoundary>
     );
 }
 
@@ -65,7 +73,6 @@ const Layout = () => {
     });
 
     useConnectionListeners();
-
     useEffect(() => connectionManager.refresh(subscriptions, users), [subscriptions, users]);
     useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
 
@@ -113,52 +120,8 @@ const Main = (props) => {
     );
 };
 
-const useConnectionListeners = () => {
-    const navigate = useNavigate();
-    useEffect(() => {
-        const handleNotification = async (subscriptionId, notification) => {
-            const added = await subscriptionManager.addNotification(subscriptionId, notification);
-            if (added) {
-                const defaultClickAction = (subscription) => navigate(subscriptionRoute(subscription));
-                await notifier.notify(subscriptionId, notification, defaultClickAction)
-            }
-        };
-        connectionManager.registerStateListener(subscriptionManager.updateState);
-        connectionManager.registerNotificationListener(handleNotification);
-        return () => {
-            connectionManager.resetStateListener();
-            connectionManager.resetNotificationListener();
-        }
-    },
-    // We have to disable dep checking for "navigate". This is fine, it never changes.
-    // eslint-disable-next-line
-    []);
-};
-
-const useAutoSubscribe = (subscriptions, selected) => {
-    const [hasRun, setHasRun] = useState(false);
-    const params = useParams();
-
-    useEffect(() => {
-        const loaded = subscriptions !== null && subscriptions !== undefined;
-        if (!loaded || hasRun) {
-            return;
-        }
-        setHasRun(true);
-        const eligible = params.topic && !selected;
-        if (eligible) {
-            const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin;
-            console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
-            (async () => {
-                const subscription = await subscriptionManager.add(baseUrl, params.topic, true);
-                poller.pollInBackground(subscription); // Dangle!
-            })();
-        }
-    }, [params, subscriptions, selected, hasRun]);
-};
-
 const updateTitle = (newNotificationsCount) => {
-    document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy web` : "ntfy web";
+    document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
 }
 
 export default App;
diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js
new file mode 100644
index 0000000000000000000000000000000000000000..87202a99edee3420549da443cf528862aa77b531
--- /dev/null
+++ b/web/src/components/ErrorBoundary.js
@@ -0,0 +1,32 @@
+import * as React from "react";
+
+class ErrorBoundary extends React.Component {
+    constructor(props) {
+        super(props);
+        this.state = { error: null, info: null };
+    }
+
+    componentDidCatch(error, info) {
+        this.setState({ error, info });
+        console.error("[ErrorBoundary] A horrible error occurred", info);
+    }
+
+    static getDerivedStateFromError(error) {
+        return { error: true, errorMessage: error.toString() }
+    }
+
+    render() {
+        if (this.state.info) {
+            return (
+                <div>
+                    <h2>Something went wrong.</h2>
+                    <pre>{this.state.error && this.state.error.toString()}</pre>
+                    <pre>{this.state.info.componentStack}</pre>
+                </div>
+            );
+        }
+        return this.props.children;
+    }
+}
+
+export default ErrorBoundary;
diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js
index 0cb1de0bffb7d74f06e384ae0704dc5295a6f42a..05805435e889e40d774ef6b77888adef95961af3 100644
--- a/web/src/components/Navigation.js
+++ b/web/src/components/Navigation.js
@@ -14,13 +14,15 @@ import SubscribeDialog from "./SubscribeDialog";
 import {Alert, AlertTitle, Badge, CircularProgress, ListSubheader} from "@mui/material";
 import Button from "@mui/material/Button";
 import Typography from "@mui/material/Typography";
-import {subscriptionRoute, topicShortUrl, topicUrl} from "../app/utils";
+import {topicShortUrl, topicUrl} from "../app/utils";
+import routes from "./routes";
 import {ConnectionState} from "../app/Connection";
 import {useLocation, useNavigate} from "react-router-dom";
 import subscriptionManager from "../app/SubscriptionManager";
 import {ChatBubble, NotificationsOffOutlined} from "@mui/icons-material";
 import Box from "@mui/material/Box";
 import notifier from "../app/Notifier";
+import config from "../app/config";
 
 const navWidth = 280;
 
@@ -71,7 +73,7 @@ const NavList = (props) => {
     const handleSubscribeSubmit = (subscription) => {
         console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
         handleSubscribeReset();
-        navigate(subscriptionRoute(subscription));
+        navigate(routes.forSubscription(subscription));
         handleRequestNotificationPermission();
     }
 
@@ -88,14 +90,14 @@ const NavList = (props) => {
             <List component="nav" sx={{ paddingTop: (showGrantPermissionsBox) ? '0' : '' }}>
                 {showGrantPermissionsBox && <PermissionAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
                 {!showSubscriptionsList &&
-                    <ListItemButton onClick={() => navigate("/")} selected={location.pathname === "/"}>
+                    <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
                         <ListItemIcon><ChatBubble/></ListItemIcon>
                         <ListItemText primary="All notifications"/>
                     </ListItemButton>}
                 {showSubscriptionsList &&
                     <>
                         <ListSubheader>Subscribed topics</ListSubheader>
-                        <ListItemButton onClick={() => navigate("/")} selected={location.pathname === "/"}>
+                        <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
                             <ListItemIcon><ChatBubble/></ListItemIcon>
                             <ListItemText primary="All notifications"/>
                         </ListItemButton>
@@ -105,7 +107,7 @@ const NavList = (props) => {
                         />
                         <Divider sx={{my: 1}}/>
                     </>}
-                <ListItemButton onClick={() => navigate("/settings")} selected={location.pathname === "/settings"}>
+                <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
                     <ListItemIcon><SettingsIcon/></ListItemIcon>
                     <ListItemText primary="Settings"/>
                 </ListItemButton>
@@ -152,7 +154,7 @@ const SubscriptionItem = (props) => {
         ? subscription.topic
         : topicShortUrl(subscription.baseUrl, subscription.topic);
     const handleClick = async () => {
-        navigate(subscriptionRoute(subscription));
+        navigate(routes.forSubscription(subscription));
         await subscriptionManager.markNotificationsRead(subscription.id);
     };
     return (
diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js
index a3278708a1bba11f59ee1d2aa62fe1f1ebfd8f09..d7713ae7536e77fde0b62f98d587f11e790563d3 100644
--- a/web/src/components/SubscribeDialog.js
+++ b/web/src/components/SubscribeDialog.js
@@ -25,7 +25,7 @@ const SubscribeDialog = (props) => {
     const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
     const handleSuccess = async () => {
         const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
-        const subscription = await subscriptionManager.add(actualBaseUrl, topic, false);
+        const subscription = await subscriptionManager.add(actualBaseUrl, topic);
         poller.pollInBackground(subscription); // Dangle!
         props.onSuccess(subscription);
     }
diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js
new file mode 100644
index 0000000000000000000000000000000000000000..b0f8787ad6b190e8503ab6fdbfcc3a61a535e235
--- /dev/null
+++ b/web/src/components/hooks.js
@@ -0,0 +1,52 @@
+import {useNavigate, useParams} from "react-router-dom";
+import {useEffect, useState} from "react";
+import subscriptionManager from "../app/SubscriptionManager";
+import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils";
+import notifier from "../app/Notifier";
+import routes from "./routes";
+import connectionManager from "../app/ConnectionManager";
+import poller from "../app/Poller";
+
+export const useConnectionListeners = () => {
+  const navigate = useNavigate();
+  useEffect(() => {
+        const handleNotification = async (subscriptionId, notification) => {
+          const added = await subscriptionManager.addNotification(subscriptionId, notification);
+          if (added) {
+            const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
+            await notifier.notify(subscriptionId, notification, defaultClickAction)
+          }
+        };
+        connectionManager.registerStateListener(subscriptionManager.updateState);
+        connectionManager.registerNotificationListener(handleNotification);
+        return () => {
+          connectionManager.resetStateListener();
+          connectionManager.resetNotificationListener();
+        }
+      },
+      // We have to disable dep checking for "navigate". This is fine, it never changes.
+      // eslint-disable-next-line
+      []);
+};
+
+export const useAutoSubscribe = (subscriptions, selected) => {
+  const [hasRun, setHasRun] = useState(false);
+  const params = useParams();
+
+  useEffect(() => {
+    const loaded = subscriptions !== null && subscriptions !== undefined;
+    if (!loaded || hasRun) {
+      return;
+    }
+    setHasRun(true);
+    const eligible = params.topic && !selected && !disallowedTopic(params.topic);
+    if (eligible) {
+      const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin;
+      console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
+      (async () => {
+        const subscription = await subscriptionManager.add(baseUrl, params.topic);
+        poller.pollInBackground(subscription); // Dangle!
+      })();
+    }
+  }, [params, subscriptions, selected, hasRun]);
+};
diff --git a/web/src/components/routes.js b/web/src/components/routes.js
new file mode 100644
index 0000000000000000000000000000000000000000..81042391c5bd66c24dbfd94af0644a1e23e090bd
--- /dev/null
+++ b/web/src/components/routes.js
@@ -0,0 +1,16 @@
+import config from "../app/config";
+import {shortUrl} from "../app/utils";
+
+const routes = {
+    root: config.appRoot,
+    settings: "/settings",
+    subscription: "/:topic",
+    subscriptionExternal: "/:baseUrl/:topic",
+    forSubscription: (subscription) => {
+        if (subscription.baseUrl !== window.location.origin) {
+            return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
+        }
+        return `/${subscription.topic}`;
+    }
+};
+export default routes;