diff --git a/djblets/extensions/resources.py b/djblets/extensions/resources.py
index 3339f4f51a2c34bed72e44d5bc8985317fa871fc..ef0a48003f160abeeb10b6698c0c1c99d21d3d7d 100644
--- a/djblets/extensions/resources.py
+++ b/djblets/extensions/resources.py
@@ -10,6 +10,8 @@ from djblets.extensions.errors import (DisablingExtensionError,
                                        EnablingExtensionError,
                                        InvalidExtensionError)
 from djblets.extensions.models import RegisteredExtension
+from djblets.extensions.signals import (extension_initialized,
+                                        extension_uninitialized)
 from djblets.urls.resolvers import DynamicURLResolver
 from djblets.webapi.decorators import (webapi_login_required,
                                        webapi_permission_required,
@@ -355,3 +357,80 @@ class ExtensionResource(WebAPIResource):
         has been uninitialized.
         """
         self._unattach_extension_resources(ext_class)
+
+
+class ExtensionRootResourceMixin(object):
+    """Mixin for Root Resources making use of Extension Resources.
+
+    As extensions are able to provide their own API resources, this mixin
+    allows a root resource to generate URI templates for non built-in
+    resources.
+
+    See Also:
+        :py:class:`~djblets.webapi.resources.root.RootResource`
+    """
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the extension resource mixin to listen for changes.
+
+        Args:
+            *args (tuple):
+                Additional positional arguments.
+
+            **kwargs (dict):
+                Additional keyword arguments.
+        """
+        super(ExtensionRootResourceMixin, self).__init__(*args, **kwargs)
+
+        extension_initialized.connect(
+            self._generate_extension_uris_for_template)
+        extension_uninitialized.connect(
+            self._remove_extension_uris_from_template)
+
+    def get_extension_resource(self):
+        """Return the associated extension resource.
+
+        Subclasses using this mixin must implement this method.
+
+        Returns:
+            djblets.extensions.resources.ExtensionResource:
+            The extension resource associated with the root resource.
+        """
+        raise NotImplementedError
+
+    def _generate_extension_uris_for_template(self, ext_class, **kwargs):
+        """Generate URI templates for a newly enabled extension.
+
+        Args:
+            ext_class djblets.extensions.extension.Extension:
+                The extension being added to the URI templates.
+
+            **kwargs (dict):
+                Additional keyword arguments.
+        """
+        ext_resource = self.get_extension_resource()
+
+        for resource in ext_class.resources:
+            partial_href = '%s/%s/' % (ext_class.id, resource.uri_name)
+
+            for entry in self.walk_resources(resource, partial_href):
+                self.register_uri_template(entry.name, entry.list_href,
+                                           ext_resource)
+
+    def _remove_extension_uris_from_template(self, ext_class, **kwargs):
+        """Remove the URI templates of an extension when disabled.
+
+        Args:
+            ext_class djblets.extensions.extension.Extension:
+                The extension being removed from the URI templates.
+
+            **kwargs (dict):
+                Additional keyword arguments.
+        """
+        ext_resource = self.get_extension_resource()
+
+        for resource in ext_class.resources:
+            partial_href = '%s/%s/' % (ext_class.id, resource.uri_name)
+
+            for entry in self.walk_resources(resource, partial_href):
+                self.unregister_uri_template(entry.name, ext_resource)
diff --git a/djblets/webapi/resources/root.py b/djblets/webapi/resources/root.py
index 10030400db5ed5c99da09d95c120dc4f49481541..e012616ed2b6145898d8a73eaa12617c4c8efb59 100644
--- a/djblets/webapi/resources/root.py
+++ b/djblets/webapi/resources/root.py
@@ -2,8 +2,11 @@
 
 from __future__ import unicode_literals
 
+from collections import namedtuple
+
 from django.conf.urls import url
 from django.http import HttpResponseNotModified
+from django.utils import six
 
 from djblets.urls.patterns import never_cache_patterns
 from djblets.webapi.errors import DOES_NOT_EXIST
@@ -22,10 +25,28 @@ class RootResource(WebAPIResource):
     name = 'root'
     singleton = True
 
+    #: A resource entry returned from :py:meth:`RootResource.walk_resources`.
+    #:
+    #: Attributes:
+    #:      name (unicode):
+    #:          The name of current resource being explored.
+    #:
+    #       list_href (unicode):
+    #:          The list_href associated with this resource.
+    #:
+    #       resource (djblets.webapi.resources.base.WebAPIResource):
+    #:          The resource object being explored.
+    #:
+    #       is_list (bool):
+    #:          Whether or not the resource is a list resource.
+    ResourceEntry = namedtuple('ResourceEntry',
+                               ('name', 'list_href', 'resource', 'is_list'))
+
     def __init__(self, child_resources=[], include_uri_templates=True):
         super(RootResource, self).__init__()
         self.list_child_resources = child_resources
         self._uri_templates = {}
+        self._registered_uri_templates = {}
         self._include_uri_templates = include_uri_templates
 
     def get_etag(self, request, obj, *args, **kwargs):
@@ -79,34 +100,69 @@ class RootResource(WebAPIResource):
             self._uri_templates = {}
 
         base_href = request.build_absolute_uri()
+
         if base_href not in self._uri_templates:
             templates = {}
-            for name, href in self._walk_resources(self, base_href):
+            unassigned_templates = self._registered_uri_templates.get(
+                None, {})
+
+            for name, href in six.iteritems(unassigned_templates):
                 templates[name] = href
 
+            for entry in self.walk_resources(self, base_href):
+                templates[entry.name] = entry.list_href
+
+                if entry.is_list:
+                    list_templates = self._registered_uri_templates.get(
+                        entry.resource, {})
+
+                    for name, href in six.iteritems(list_templates):
+                        templates[name] = '%s%s' % (entry.list_href, href)
+
             self._uri_templates[base_href] = templates
 
         return self._uri_templates[base_href]
 
-    def _walk_resources(self, resource, list_href):
-        yield resource.name_plural, list_href
+    @classmethod
+    def walk_resources(cls, resource, list_href):
+        """Yield all URI endpoints associated with a specified resource.
+
+        Args:
+            resource djblets.webapi.resources.WebAPIResource:
+                The starting point for searching the resource tree.
+
+            list_href (unicode):
+                The path to the list resource, relative to the WebAPIResource
+                provided. Used as a component of the URL in the API.
+
+        Yields:
+            RootResource.ResourceEntry:
+            Resource entries for all sub-resources.
+        """
+        yield cls.ResourceEntry(name=resource.name_plural,
+                                list_href=list_href,
+                                resource=resource,
+                                is_list=True)
 
         for child in resource.list_child_resources:
-            child_href = list_href + child.uri_name + '/'
+            child_href = '%s%s/' % (list_href, child.uri_name)
 
-            for name, href in self._walk_resources(child, child_href):
-                yield name, href
+            for entry in cls.walk_resources(child, child_href):
+                yield entry
 
         if resource.uri_object_key:
             object_href = '%s{%s}/' % (list_href, resource.uri_object_key)
 
-            yield resource.name, object_href
+            yield cls.ResourceEntry(name=resource.name,
+                                    list_href=object_href,
+                                    resource=resource,
+                                    is_list=False)
 
             for child in resource.item_child_resources:
-                child_href = object_href + child.uri_name + '/'
+                child_href = '%s%s/' % (object_href, child.uri_name)
 
-                for name, href in self._walk_resources(child, child_href):
-                    yield name, href
+                for entry in cls.walk_resources(child, child_href):
+                    yield entry
 
     def api_404_handler(self, request, api_format=None, *args, **kwargs):
         """Default handler at the end of the URL patterns.
@@ -129,3 +185,50 @@ class RootResource(WebAPIResource):
             '', url(r'.*', self.api_404_handler))
 
         return urlpatterns
+
+    def register_uri_template(self, name, relative_path,
+                              relative_resource=None):
+        """Register the specified resource for URI template serialization.
+
+        This adds the specified name and relative resource to the Root
+        Resource's URI templates.
+
+        Args:
+            name (unicode):
+                The name of the associated resource being added to templates.
+
+            relative_path (unicode):
+                The path of the API resource relative to its parent resources.
+
+            relative_resource (djblets.extensions.resources.ExtensionResource,
+                               optional):
+                The resource instance associated with this URI template.
+        """
+        # Clear the cache so that new lookups can detect newly added templates.
+        self._uri_templates = {}
+
+        templates = self._registered_uri_templates.setdefault(
+            relative_resource, {})
+        templates[name] = relative_path
+
+    def unregister_uri_template(self, name, relative_resource=None):
+        """Unregister the specified resource for URI template serialization.
+
+        This removes the specified name and relative resource to the Root
+        Resource's URI templates.
+
+        Args:
+            name (unicode):
+                The name of the resource being removed from templates.
+
+            relative_resource (djblets.extensions.resources.ExtensionResource,
+                               optional):
+                The resource instance associated with this URI template.
+        """
+        # Clear the cache so that new lookups can detect newly added templates.
+        self._uri_templates = {}
+
+        try:
+            del self._registered_uri_templates[relative_resource][name]
+        except KeyError:
+            pass
diff --git a/djblets/webapi/tests/test_extension_resource_mixin.py b/djblets/webapi/tests/test_extension_resource_mixin.py
new file mode 100644
index 0000000000000000000000000000000000000000..860e6b4b9bb5a79e12b529ded4438dabc89971d9
--- /dev/null
+++ b/djblets/webapi/tests/test_extension_resource_mixin.py
@@ -0,0 +1,114 @@
+"""Unit tests for the ExtensionResource mixin."""
+
+from __future__ import unicode_literals
+
+from django.test import RequestFactory
+
+from djblets.extensions.extension import Extension
+from djblets.extensions.manager import ExtensionManager
+from djblets.extensions.resources import (ExtensionResource,
+                                          ExtensionRootResourceMixin)
+from djblets.extensions.testing import ExtensionTestCaseMixin
+from djblets.testing.testcases import TestCase
+from djblets.webapi.resources import RootResource, WebAPIResource
+
+
+class TestChildResource1(WebAPIResource):
+    """Test api resource item under MockResourceOne."""
+
+    name = 'test_child_resource_item_1'
+    uri_object_key = 'test_child_resource_item_1_id'
+
+
+class TestChildResource2(WebAPIResource):
+    """Test api resource item under MockResourceTwo."""
+
+    name = 'test_child_resource_item_2'
+    uri_object_key = 'test_child_resource_item_2_id'
+
+
+class TestResource1(WebAPIResource):
+    """Test api resource exposed by MockExtension."""
+
+    name = 'test_resource_1'
+    item_child_resources = [TestChildResource1()]
+
+
+class TestResource2(WebAPIResource):
+    """Test api resource exposed by MockExtension."""
+
+    name = 'test_resource_2'
+    item_child_resources = [TestChildResource2()]
+
+
+class TestExtension(Extension):
+    """Test extension for use by ExtensionTemplateTests."""
+
+    resources = [TestResource1(), TestResource2()]
+
+
+class TestRootResource(ExtensionRootResourceMixin, RootResource):
+    """RootResource for testing ExtensionRootResourceMixin."""
+
+    def get_extension_resource(self):
+        """Return the mocked extension resource.
+
+        Returns:
+            djblets.extensions.resources.ExtensionResource:
+            The mock extension resource.
+        """
+        return self.list_child_resources[0]
+
+
+class ExtensionRootResourceMixinTests(ExtensionTestCaseMixin, TestCase):
+    """Unit tests for the ExtensionRootResourceMixin."""
+
+    extension_class = TestExtension
+
+    def setUp(self):
+        self.mgr = ExtensionManager('')
+        self.ext_resource = ExtensionResource(self.mgr)
+        self.root_resource = TestRootResource([self.ext_resource])
+        self.request = RequestFactory().get('/')
+
+        super(ExtensionRootResourceMixinTests, self).setUp()
+
+    def test_generate_extension_uris_for_template(self):
+        """Testing ExtensionRootResourceMixin generates URI templates when
+        extensions are initialized
+        """
+        self.root_resource._generate_extension_uris_for_template(
+            self.extension)
+        actual_result = self.root_resource.get_uri_templates(
+            self.request)
+        self.assertEqual(actual_result, {
+            'extensions': 'http://testserver/extensions/',
+            'extension': 'http://testserver/extensions/{extension_name}/',
+            'test_resource_1s': (
+                'http://testserver/extensions/djblets.webapi.tests.'
+                'test_extension_resource_mixin.'
+                'TestExtension/test-resource-1s/'
+            ),
+            'test_resource_2s': (
+                'http://testserver/extensions/djblets.webapi.tests.'
+                'test_extension_resource_mixin.'
+                'TestExtension/test-resource-2s/'
+            ),
+            'root': 'http://testserver/',
+        })
+
+    def test_remove_extension_uris_from_template(self):
+        """Testing ExtensionRootResourceMixin removes URI templates when
+        extensions are disabled
+        """
+        self.root_resource._generate_extension_uris_for_template(
+            self.extension)
+        self.root_resource._remove_extension_uris_from_template(
+            self.extension)
+        actual_result = self.root_resource.get_uri_templates(
+            self.request)
+        self.assertEqual(actual_result, {
+            'extensions': u'http://testserver/extensions/',
+            'root': 'http://testserver/',
+            'extension': 'http://testserver/extensions/{extension_name}/',
+        })
diff --git a/djblets/webapi/tests/test_root_resource.py b/djblets/webapi/tests/test_root_resource.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d2d92c44c7476b53be96bf606a47e1f587f4e6a
--- /dev/null
+++ b/djblets/webapi/tests/test_root_resource.py
@@ -0,0 +1,75 @@
+"""Unit tests for the Root Resource."""
+
+from djblets.extensions.manager import ExtensionManager
+from djblets.extensions.resources import ExtensionResource
+from djblets.testing.testcases import TestCase
+from djblets.webapi.resources import RootResource
+
+
+class RootResourceTemplateRegistrationTests(TestCase):
+    """Unit tests for the (un)registration of templates in RootResource."""
+
+    def setUp(self):
+        super(RootResourceTemplateRegistrationTests, self).setUp()
+
+        self.ext_mgr = ExtensionManager('')
+        self.ext_res = ExtensionResource(self.ext_mgr)
+        self.root_res = RootResource([self.ext_res])
+        self.root_res._registered_uri_templates = {
+            self.ext_res: {
+                'extensions': 'http://localhost:8080/api/extensions/'
+            },
+            None: {
+                'extensions': 'http://localhost:8080/api/extensions/none/'
+            },
+        }
+
+    def test_register_uri_template_without_relative_resource(self):
+        """Testing register_uri_templates without a relative resource"""
+        self.root_res.register_uri_template(name='key', relative_path='value')
+        actual_result = self.root_res._registered_uri_templates[None]
+        self.assertEqual(actual_result, {
+            'extensions': 'http://localhost:8080/api/extensions/none/',
+            'key': 'value',
+        })
+
+    def test_register_uri_template_with_relative_resource(self):
+        """Testing register_uri_templates with a relative resource"""
+        mock_extension_resource = ExtensionResource(self.ext_mgr)
+        self.root_res.register_uri_template(
+            name='key',
+            relative_path='value',
+            relative_resource=mock_extension_resource)
+        actual_result = self.root_res._registered_uri_templates[
+            mock_extension_resource]
+        self.assertEqual(actual_result, {'key': 'value'})
+
+    def test_unregister_uri_template_without_relative_resource(self):
+        """Testing unregister_uri_template without a relative resource"""
+        self.root_res.unregister_uri_template('extensions')
+        self.assertFalse(self.root_res._registered_uri_templates[None])
+
+    def test_unregister_uri_template_with_relative_resource(self):
+        """Testing unregister_uri_template with a relative resource"""
+        self.root_res.unregister_uri_template('extensions', self.ext_res)
+        self.assertFalse(self.root_res._registered_uri_templates[
+            self.ext_res])
+
+    def test_register_uri_template_clears_uri_template_cache(self):
+        """Testing register_uri_templates clears the URI template cache"""
+        self.root_res._uri_templates = {
+            'key1': 'value1',
+            'key2': 'value2',
+        }
+        self.root_res.register_uri_template(
+            'extension_name', 'some/relative/path/')
+        self.assertEqual(self.root_res._uri_templates, {})
+
+    def test_unregister_uri_template_clears_uri_template_cache(self):
+        """Testing unregister_uri_templates clears the URI template cache"""
+        self.root_res._uri_templates = {
+            'key1': 'value1',
+            'key2': 'value2',
+        }
+        self.root_res.unregister_uri_template('extension-1')
+        self.assertEqual(self.root_res._uri_templates, {})
