diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 92bc193cb10..77d88efbb0a 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -7,10 +7,12 @@ import '../api/core.dart';
 import '../api/model/model.dart';
 import '../model/binding.dart';
 import '../model/content.dart';
+import '../model/internal_link.dart';
 import '../model/store.dart';
 import 'code_block.dart';
 import 'dialog.dart';
 import 'lightbox.dart';
+import 'message_list.dart';
 import 'store.dart';
 import 'text.dart';
 
@@ -670,6 +672,14 @@ void _launchUrl(BuildContext context, String urlString) async {
     return;
   }
 
+  final internalNarrow = parseInternalLink(url, store);
+  if (internalNarrow != null) {
+    Navigator.push(context,
+      MessageListPage.buildRoute(context: context,
+        narrow: internalNarrow));
+    return;
+  }
+
   bool launched = false;
   String? errorMessage;
   try {
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 8078d16ab7c..6ffa8cc4e1f 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -7,13 +7,21 @@ import 'package:flutter_test/flutter_test.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:zulip/api/core.dart';
 import 'package:zulip/model/content.dart';
+import 'package:zulip/model/narrow.dart';
 import 'package:zulip/widgets/content.dart';
+import 'package:zulip/widgets/message_list.dart';
+import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/store.dart';
 
+import '../api/fake_api.dart';
 import '../example_data.dart' as eg;
 import '../model/binding.dart';
+import '../model/message_list_test.dart';
 import '../test_images.dart';
+import '../test_navigation.dart';
 import 'dialog_checks.dart';
+import 'message_list_checks.dart';
+import 'page_checks.dart';
 
 void main() {
   TestZulipBinding.ensureInitialized();
@@ -155,6 +163,90 @@ void main() {
     });
   });
 
+  group('Internal links', () {
+    Future<void> prepareContent(WidgetTester tester, {
+      NavigatorObserver? navigatorObserver,
+      required String html,
+    }) async {
+      addTearDown(testBinding.reset);
+      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
+        streams: [eg.stream(streamId: 1, name: 'check')],
+      ));
+      final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+      final connection = store.connection as FakeApiConnection;
+      connection.prepare(json: newestResult(
+        foundOldest: true,
+        messages: [],
+      ).toJson());
+      await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp(
+        navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [],
+        home: PerAccountStoreWidget(accountId: eg.selfAccount.id,
+          child: BlockContentList(
+            nodes: parseContent(html).nodes)))));
+      await tester.pump();
+      await tester.pump();
+    }
+
+    testWidgets('internal links are resolved: stream', (tester) async {
+      final pushedRoutes = <Route<dynamic>>[];
+      final testNavObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      await prepareContent(tester,
+        navigatorObserver: testNavObserver,
+        html: '<p><a href="/#narrow/stream/1-check">stream</a></p>');
+
+      await tester.tap(find.text('stream'));
+      check(testBinding.takeLaunchUrlCalls()).isEmpty();
+      check(pushedRoutes).last.isA<WidgetRoute>()
+        .page.isA<MessageListPage>()
+        .narrow.equals(const StreamNarrow(1));
+    });
+
+    testWidgets('internal links are resolved: topic', (tester) async {
+      final pushedRoutes = <Route<dynamic>>[];
+      final testNavObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      await prepareContent(tester,
+        navigatorObserver: testNavObserver,
+        html: '<p><a href="/#narrow/stream/1-check/topic/my.20topic">topic</a></p>');
+
+      await tester.tap(find.text('topic'));
+      check(testBinding.takeLaunchUrlCalls()).isEmpty();
+      check(pushedRoutes).last.isA<WidgetRoute>()
+        .page.isA<MessageListPage>()
+        .narrow.equals(const TopicNarrow(1, 'my topic'));
+    });
+
+    testWidgets('internal links are resolved: dm', (tester) async {
+      final pushedRoutes = <Route<dynamic>>[];
+      final testNavObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      await prepareContent(tester,
+        navigatorObserver: testNavObserver,
+        html: '<p><a href="/#narrow/dm/1-123-group">dm</a></p>');
+
+      await tester.tap(find.text('dm'));
+      check(testBinding.takeLaunchUrlCalls()).isEmpty();
+      check(pushedRoutes).last.isA<WidgetRoute>()
+        .page.isA<MessageListPage>()
+        .narrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId));
+    });
+
+    testWidgets('invalid internal links are not followed', (tester) async {
+      final pushedRoutes = <Route<dynamic>>[];
+      final testNavObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      await prepareContent(tester,
+        navigatorObserver: testNavObserver,
+        html: '<p><a href="/#narrow/stream/1-check/topic">invalid</a></p>');
+      await tester.tap(find.text('invalid'));
+      final expectedUrl = '${eg.realmUrl}#narrow/stream/1-check/topic';
+      const expectedMode = LaunchMode.externalApplication;
+      check(testBinding.takeLaunchUrlCalls())
+        .single.equals((url: Uri.parse(expectedUrl), mode: expectedMode));
+    });
+  });
+
   group('UnicodeEmoji', () {
     Future<void> prepareContent(WidgetTester tester, String html) async {
       await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes)));