diff --git a/src/InvalidUrlView.js b/src/InvalidUrlView.js
new file mode 100644
index 0000000000000000000000000000000000000000..ecc66dac44062e4fd8ac52c7fe90fc34a0b756fc
--- /dev/null
+++ b/src/InvalidUrlView.js
@@ -0,0 +1,56 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {TemplateView} from "./utils/TemplateView.js";
+import {LinkKind, IdentifierKind} from "./Link.js";
+
+export class InvalidUrlView extends TemplateView {
+    render(t, vm) {
+        return t.div({ className: "DisclaimerView card" }, [
+            t.h1("Invalid URL"),
+            t.p([
+                'The link you have entered is not valid. If you like, you can ',
+                t.a({ href: "#/" }, 'return to the home page.')
+            ]),
+            vm.validFixes.length ? this._renderValidFixes(t, vm.validFixes) : [],
+        ]);
+    }
+
+    _describeRoom(identifierKind) {
+        return identifierKind === IdentifierKind.RoomAlias ? "room alias" : "room";
+    }
+
+    _describeLinkKind(linkKind, identifierKind) {
+        switch (linkKind) {
+            case LinkKind.Room: return `The ${this._describeRoom(identifierKind)} `;
+            case LinkKind.User: return "The user ";
+            case LinkKind.Group: return "The group ";
+            case LinkKind.Event: return `An event in ${this._describeRoom(identifierKind)} `;
+        }
+    }
+
+    _renderValidFixes(t, validFixes) {
+        return t.p([
+            'Did you mean any of the following?',
+            t.ul(validFixes.map(fix =>
+                t.li([
+                    this._describeLinkKind(fix.link.kind, fix.link.identifierKind),
+                    t.a({ href: fix.url }, fix.link.identifier)
+                ])
+            ))
+        ]);
+    }
+}
diff --git a/src/InvalidUrlViewModel.js b/src/InvalidUrlViewModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..dc0bf560e4e685caf9c31d8ec2b145b6a7c4e1b1
--- /dev/null
+++ b/src/InvalidUrlViewModel.js
@@ -0,0 +1,25 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {ViewModel} from "./utils/ViewModel.js";
+import {tryFixUrl} from "./Link.js";
+
+export class InvalidUrlViewModel extends ViewModel {
+    constructor(options) {
+        super(options);
+        this.validFixes = tryFixUrl(options.fragment);
+    }
+}
diff --git a/src/Link.js b/src/Link.js
index 079d7be4d7e7fc7455fbc0f24708f125ba9168f3..cb7653f29d8e64ae24a1c63bfa4d606916cd4ad5 100644
--- a/src/Link.js
+++ b/src/Link.js
@@ -71,6 +71,23 @@ export const LinkKind = createEnum(
     "Event"
 )
 
+export function tryFixUrl(fragment) {
+    const attempts = [];
+    const afterHash = fragment.substring(fragment.startsWith("#/") ? 2 : 1);
+    attempts.push('#/@' + afterHash);
+    attempts.push('#/#' + afterHash);
+    attempts.push('#/!' + afterHash);
+
+    const validAttempts = [];
+    for (const attempt of [...new Set(attempts)]) {
+        const link = Link.parse(attempt);
+        if (link) {
+            validAttempts.push({ url: attempt, link });
+        }
+    }
+    return validAttempts;
+}
+
 export class Link {
     static validateIdentifier(identifier) {
         return !!(
@@ -105,9 +122,10 @@ export class Link {
             webInstances = getWebInstanceMap(queryParams);
         }
 
-        if (linkStr.startsWith("#/")) {
-            linkStr = linkStr.substr(2);
+        if (!linkStr.startsWith("#/")) {
+            return null;
         }
+        linkStr = linkStr.substr(2);
 
         const [identifier, eventId] = linkStr.split("/");
 
diff --git a/src/RootView.js b/src/RootView.js
index 5669f367b2f43d4a13474ab001115bb033548e01..3dc2d233211cda2fafc9cd03d50c203163680cd7 100644
--- a/src/RootView.js
+++ b/src/RootView.js
@@ -19,10 +19,12 @@ import {OpenLinkView} from "./open/OpenLinkView.js";
 import {CreateLinkView} from "./create/CreateLinkView.js";
 import {LoadServerPolicyView} from "./policy/LoadServerPolicyView.js";
 import {DisclaimerView} from "./disclaimer/DisclaimerView.js";
+import {InvalidUrlView} from "./InvalidUrlView.js";
 
 export class RootView extends TemplateView {
     render(t, vm) {
         return t.div({className: "RootView"}, [
+            t.mapView(vm => vm.invalidUrlViewModel, invalidVM => invalidVM ? new InvalidUrlView(invalidVM) : null),
             t.mapView(vm => vm.showDisclaimer, disclaimer => disclaimer ? new DisclaimerView() : null),
             t.mapView(vm => vm.openLinkViewModel, vm => vm ? new OpenLinkView(vm) : null),
             t.mapView(vm => vm.createLinkViewModel, vm => vm ? new CreateLinkView(vm) : null),
diff --git a/src/RootViewModel.js b/src/RootViewModel.js
index 7c2d35ed98a0512c9bd50f0b35816b4ea4eaeab9..39f6733525e0220705712e63422a4cb7234348cf 100644
--- a/src/RootViewModel.js
+++ b/src/RootViewModel.js
@@ -20,6 +20,7 @@ import {OpenLinkViewModel} from "./open/OpenLinkViewModel.js";
 import {createClients} from "./open/clients/index.js";
 import {CreateLinkViewModel} from "./create/CreateLinkViewModel.js";
 import {LoadServerPolicyViewModel} from "./policy/LoadServerPolicyViewModel.js";
+import {InvalidUrlViewModel} from "./InvalidUrlViewModel.js";
 import {Platform} from "./Platform.js";
 
 export class RootViewModel extends ViewModel {
@@ -29,26 +30,23 @@ export class RootViewModel extends ViewModel {
         this.openLinkViewModel = null;
         this.createLinkViewModel = null;
         this.loadServerPolicyViewModel = null;
+        this.invalidUrlViewModel = null;
         this.showDisclaimer = false;
         this.preferences.on("canClear", () => {
             this.emitChange();
         });
     }
 
-    _updateChildVMs(oldLink) {
-        if (this.link) {
-            this.createLinkViewModel = null;
-            if (!oldLink || !oldLink.equals(this.link)) {
-                this.openLinkViewModel = new OpenLinkViewModel(this.childOptions({
-                    link: this.link,
-                    clients: createClients(),
-                }));
-            }
-        } else {
+    _updateChildVMs(newLink, oldLink) {
+        this.link = newLink;
+        if (!newLink) {
             this.openLinkViewModel = null;
-            this.createLinkViewModel = new CreateLinkViewModel(this.childOptions());
+        } else if (!oldLink || !oldLink.equals(newLink)) {
+            this.openLinkViewModel = new OpenLinkViewModel(this.childOptions({
+                link: newLink,
+                clients: createClients(),
+            }));
         }
-        this.emitChange();
     }
 
     _hideLinks() {
@@ -58,24 +56,35 @@ export class RootViewModel extends ViewModel {
     }
 
     updateHash(hash) {
+        // All view models except openLink are re-created anyway. Might as well
+        // clear them to avoid having to manually reset (n-1)/n view models in every case.
+        // That just doesn't scale well when we add new views.
+        const oldLink = this.link;
+        this.invalidUrlViewModel = null;
         this.showDisclaimer = false;
+        this.loadServerPolicyViewModel = null;
+        this.createLinkViewModel = null;
+        let newLink;
         if (hash.startsWith("#/policy/")) {
             const server = hash.substr(9);
-            this._hideLinks();
+            this._updateChildVMs(null, oldLink);
             this.loadServerPolicyViewModel = new LoadServerPolicyViewModel(this.childOptions({server}));
             this.loadServerPolicyViewModel.load();
-            this.emitChange();
         } else if (hash.startsWith("#/disclaimer/")) {
-            this._hideLinks();
-            this.loadServerPolicyViewModel = null;
+            this._updateChildVMs(null, oldLink);
             this.showDisclaimer = true;
-            this.emitChange();
+        }  else if (hash === "" || hash === "#" || hash === "#/") {
+            this._updateChildVMs(null, oldLink);
+            this.createLinkViewModel = new CreateLinkViewModel(this.childOptions());
+        } else if (newLink = Link.parse(hash)) {
+            this._updateChildVMs(newLink, oldLink);
         } else {
-            const oldLink = this.link;
-            this.loadServerPolicyViewModel = null;
-            this.link = Link.parse(hash);
-            this._updateChildVMs(oldLink);
+            this._updateChildVMs(null, oldLink);
+            this.invalidUrlViewModel = new InvalidUrlViewModel(this.childOptions({
+                fragment: hash
+            }));
         }
+        this.emitChange();
     }
 
     clearPreferences() {