diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py
index 770fff1866..2f9d0298b8 100644
--- a/jupyter_server/base/handlers.py
+++ b/jupyter_server/base/handlers.py
@@ -1188,6 +1188,19 @@ def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]
         return super().get(path, include_body)
 
 
+class ExtensionAppsHandler(JupyterHandler):
+    """Return Jupyter Server extension web applications."""
+
+    @allow_unauthenticated
+    def get(self) -> None:
+        self.set_header("Content-Type", "application/json")
+        if self.serverapp:
+            self.finish(json.dumps(self.serverapp.extension_manager.extension_web_apps()))
+        else:
+            # self.serverapp can be None
+            raise web.HTTPError(500, "Server has not started correctly.")
+
+
 # -----------------------------------------------------------------------------
 # URL pattern fragments for reuse
 # -----------------------------------------------------------------------------
@@ -1205,4 +1218,5 @@ def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]
     (r"api", APIVersionHandler),
     (r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler),
     (r"/metrics", PrometheusMetricsHandler),
+    (r"/extensions", ExtensionAppsHandler),
 ]
diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py
index b8c52ca9e5..f6c97ec469 100644
--- a/jupyter_server/extension/manager.py
+++ b/jupyter_server/extension/manager.py
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import importlib
+import re
 from itertools import starmap
 
 from tornado.gen import multi
@@ -13,6 +14,8 @@
 from .config import ExtensionConfigManager
 from .utils import ExtensionMetadataError, ExtensionModuleNotFound, get_loader, get_metadata
 
+RE_SLASH = x = re.compile(r"/+")  # match any number of slashes
+
 
 class ExtensionPoint(HasTraits):
     """A simple API for connecting to a Jupyter Server extension
@@ -291,6 +294,25 @@ def extension_apps(self):
             for name, extension in self.extensions.items()
         }
 
+    @property
+    def extension_web_apps(self):
+        """Return Jupyter Server extension web applications.
+
+        Some Jupyter Server extensions provide web applications
+        (e.g. Jupyter Lab), other's don't (e.g. Jupyter LSP).
+
+        This returns a mapping of {extension_name: web_app_endpoint} for all
+        extensions which provide a default_url (i.e. a web application).
+        """
+        return {
+            app.name: RE_SLASH.sub("/", f"{self.serverapp.base_url}/{app.default_url}")
+            for extension_apps in self.serverapp.extension_manager.extension_apps.values()
+            # filter out extensions that do not provide a default_url OR
+            # set it to the root endpoint.
+            for app in extension_apps
+            if getattr(app, "default_url", "/") != "/"
+        }
+
     @property
     def extension_points(self):
         """Return mapping of extension point names and ExtensionPoint objects."""
diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py
index 26f38464cd..390a934552 100644
--- a/tests/extension/mockextensions/app.py
+++ b/tests/extension/mockextensions/app.py
@@ -50,6 +50,7 @@ class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp):
     static_paths = [STATIC_PATH]  # type:ignore[assignment]
     mock_trait = Unicode("mock trait", config=True)
     loaded = False
+    default_url = "/mockextension"
 
     serverapp_config = {"jpserver_extensions": {"tests.extension.mockextensions.mock1": True}}
 
diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py
index d1add54344..62f03af2ce 100644
--- a/tests/extension/test_app.py
+++ b/tests/extension/test_app.py
@@ -191,3 +191,18 @@ async def test_events(jp_serverapp, jp_fetch):
     stream.truncate(0)
     stream.seek(0)
     assert output["msg"] == "Hello, world!"
+
+
+async def test_extension_web_apps(jp_serverapp):
+    jp_serverapp.extension_manager.load_all_extensions()
+
+    # there should be (at least) two extension applications
+    assert set(jp_serverapp.extension_manager.extension_apps) == {
+        "tests.extension.mockextensions",
+        "jupyter_server_terminals",
+    }
+
+    # but only one extension web application
+    assert jp_serverapp.extension_manager.extension_web_apps == {
+        "mockextension": "/a%40b/mockextension"
+    }