diff --git a/djblets/extensions/base.py b/djblets/extensions/base.py
index 46fb2039cd18181ac5c053f6088a1d4c483dcfc7..ca08d5d304be1e63d351d014545437e2741f5423 100644
--- a/djblets/extensions/base.py
+++ b/djblets/extensions/base.py
@@ -152,6 +152,12 @@ class Extension(object):
     Extensions should list all other extension names that they require in
     :py:attr:`requirements`.
 
+    Extensions that have a JavaScript component can list their common
+    JavaScript files in py:attr:`js_files`, and the full name of their
+    JavaScript Extension subclass in :py:attr:`js_model_class`. It will
+    then be instantiated when loading any page that uses the
+    ``init_js_extensions`` template tag.
+
     If an extension has any middleware, it should set :py:attr:`middleware`
     to a list of class names. This extension's middleware will be loaded after
     any middleware belonging to any extensions in the :py:attr:`requirements`
@@ -166,6 +172,9 @@ class Extension(object):
     apps = []
     middleware = []
 
+    js_files = []
+    js_model_class = None
+
     def __init__(self, extension_manager):
         self.extension_manager = extension_manager
         self.hooks = set()
@@ -197,6 +206,14 @@ class Extension(object):
         return self._admin_urlconf_module
     admin_urlconf = property(_get_admin_urlconf)
 
+    def get_js_model_data(self):
+        """Returns model data for the Extension instance in JavaScript.
+
+        Subclasses can override this to return custom data to pass to
+        the extension.
+        """
+        return {}
+
 
 class ExtensionInfo(object):
     """Information on an extension.
diff --git a/djblets/extensions/templates/extensions/extension_list.html b/djblets/extensions/templates/extensions/extension_list.html
index 042405605a6165c983f23d31c472e4959f63bedb..59d83436a446c6338b7bad9c56edcc0f8c0d372b 100644
--- a/djblets/extensions/templates/extensions/extension_list.html
+++ b/djblets/extensions/templates/extensions/extension_list.html
@@ -8,7 +8,7 @@
 {% compressed_css "djblets-admin" %}
 {% include "js/jquery.html" %}
 {% include "js/jquery-ui.html" %}
-{% compressed_js "djblets-extensions" %}
+{% compressed_js "djblets-extensions-admin" %}
 
 {{block.super}}
 {% endblock %}
diff --git a/djblets/extensions/templates/extensions/init_js_extensions.html b/djblets/extensions/templates/extensions/init_js_extensions.html
new file mode 100644
index 0000000000000000000000000000000000000000..025ccab46b8674d71813a23e4b03019ec276228a
--- /dev/null
+++ b/djblets/extensions/templates/extensions/init_js_extensions.html
@@ -0,0 +1,21 @@
+{% load djblets_js %}
+
+{% for extension in extension_manager.get_enabled_extensions %}
+{%  if extension.js_files %}
+{%   for js_file in extension.js_files %}
+<script src="{{MEDIA_URL}}ext/{{extension.info.package_name}}/{{js_file}}"></script>
+{%   endfor %}
+{%   if extension.js_model_class %}
+<script>
+    new {{extension.js_model_class}}({
+{%    for key, value in extension.get_js_model_data.items %}
+        {{key|json_dumps}}: {{value|json_dumps}},
+{%    endfor %}
+        id: '{{extension.id}}',
+        name: {{extension.info.name|json_dumps}},
+        settings: {{extension.settings|json_dumps}}
+    });
+</script>
+{%   endif %}
+{%  endif %}
+{% endfor %}
diff --git a/djblets/extensions/templatetags/djblets_extensions.py b/djblets/extensions/templatetags/djblets_extensions.py
index e5580a686c82daa2664245ce02fd322af4eff751..87f92619ea3f258a12f61eb9fdb202acb62d5dec 100644
--- a/djblets/extensions/templatetags/djblets_extensions.py
+++ b/djblets/extensions/templatetags/djblets_extensions.py
@@ -1,5 +1,6 @@
 from django import template
 
+from djblets.extensions.base import get_extension_managers
 from djblets.extensions.hooks import TemplateHook
 from djblets.util.decorators import basictag
 
@@ -20,3 +21,22 @@ def template_hook_point(context, name):
             s += hook.render_to_string(context.get('request', None), context)
 
     return s
+
+
+@register.inclusion_tag('extensions/init_js_extensions.html',
+                        takes_context=True)
+def init_js_extensions(context, key):
+    """Initializes all JavaScript extensions.
+
+    Each extension's required JavaScript files will be loaded in the page,
+    and their JavaScript-side Extension subclasses will be instantiated.
+    """
+    for manager in get_extension_managers():
+        if manager.key == key:
+            return {
+                'extension_manager': manager,
+                'request': context['request'],
+                'MEDIA_URL': context['MEDIA_URL'],
+            }
+
+    return {}
diff --git a/djblets/settings.py b/djblets/settings.py
index 5dfda68be0e8df7ebaf0845924e4eb6891c62475..c3784f52675e18c588381c9a6142133b8188e756 100644
--- a/djblets/settings.py
+++ b/djblets/settings.py
@@ -31,8 +31,17 @@ PIPELINE_JS = {
         'source_filenames': ('djblets/js/datagrid.js',),
         'output_filename': 'djblets/js/datagrid.min.js',
     },
+    'djblets-extensions-admin': {
+        'source_filenames': ('djblets/js/extensions/admin.js',),
+        'output_filename': 'djblets/js/extensions-admin.min.js',
+    },
     'djblets-extensions': {
-        'source_filenames': ('djblets/js/extensions.js',),
+        'source_filenames': (
+            'djblets/js/extensions/base.js',
+            'djblets/js/extensions/models/extensionModel.js',
+            'djblets/js/extensions/models/extensionHookModel.js',
+            'djblets/js/extensions/models/extensionHookPointModel.js',
+        ),
         'output_filename': 'djblets/js/extensions.min.js',
     },
     'djblets-gravy': {
diff --git a/djblets/static/djblets/js/extensions/base.js b/djblets/static/djblets/js/extensions/base.js
new file mode 100644
index 0000000000000000000000000000000000000000..0021887c976315858f84c7119dd79b1168fd4828
--- /dev/null
+++ b/djblets/static/djblets/js/extensions/base.js
@@ -0,0 +1,3 @@
+if (!window.Djblets) {
+    window.Djblets = {};
+}
diff --git a/djblets/static/djblets/js/extensions/models/extensionHookModel.js b/djblets/static/djblets/js/extensions/models/extensionHookModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..d3520f0c2e0f486a448963442cd1d038abaf7098
--- /dev/null
+++ b/djblets/static/djblets/js/extensions/models/extensionHookModel.js
@@ -0,0 +1,69 @@
+/*
+ * Base class for hooks that an extension can use to augment functionality.
+ *
+ * Each type of hook represents a point in the codebase that an extension
+ * is able to plug functionality into.
+ *
+ * Subclasses are expected to set a hookPoint field in the prototype to an
+ * instance of ExtensionPoint.
+ *
+ * Instances of an ExtensionHook subclass that extensions create will be
+ * automatically registered with both the extension and the list of hooks
+ * for that ExtensionHook subclass.
+ *
+ * Callers that use ExtensionHook subclasses to provide functionality can
+ * use the subclass's each() method to loop over all registered hooks.
+ */
+Djblets.ExtensionHook = Backbone.Model.extend({
+    /*
+     * An ExtensionHookPoint instance.
+     *
+     * This must be defined and instantiated by a subclass of ExtensionHook,
+     * but not by subclasses created by extensions.
+     */
+    hookPoint: null,
+
+    defaults: {
+        extension: null
+    },
+
+    /*
+     * Initializes the hook.
+     *
+     * This will add the instance of the hook to the extension's list of
+     * hooks, and to the list of known hook instances for this hook point.
+     *
+     * After initialization, setUpHook will be called, which a subclass
+     * can use to provide additional setup.
+     */
+    initialize: function() {
+        var extension = this.get('extension');
+
+        console.assert(this.hookPoint,
+                       'This ExtensionHook subclass must define hookPoint');
+        console.assert(extension,
+                       'An Extension instance must be passed to ExtensionHook');
+
+        extension.hooks.push(this);
+        this.hookPoint.addHook(this);
+
+        this.setUpHook();
+    },
+
+    /*
+     * Sets up additional state for the hook.
+     *
+     * This can be overridden by subclasses to provide additional
+     * functionality.
+     */
+    setUpHook: function() {
+    }
+}, {
+    /*
+     * Loops through each registered hook instance and calls the given
+     * callback.
+     */
+    each: function(cb, context) {
+        _.each(this.prototype.hookPoint.hooks, cb, context);
+    }
+});
diff --git a/djblets/static/djblets/js/extensions/models/extensionHookPointModel.js b/djblets/static/djblets/js/extensions/models/extensionHookPointModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..2756c9d15d128068c43402f629ec5eda64c37706
--- /dev/null
+++ b/djblets/static/djblets/js/extensions/models/extensionHookPointModel.js
@@ -0,0 +1,18 @@
+/*
+ * Defines a point where extension hooks can plug into.
+ *
+ * This is meant to be instantiated and provided as a 'hookPoint' field on
+ * an ExtensionHook subclass, in order to provide a place to hook into.
+ */
+Djblets.ExtensionHookPoint = Backbone.Model.extend({
+    initialize: function() {
+        this.hooks = [];
+    },
+
+    /*
+     * Adds a hook instance to the list of known hooks.
+     */
+    addHook: function(hook) {
+        this.hooks.push(hook);
+    }
+});
diff --git a/djblets/static/djblets/js/extensions/models/extensionModel.js b/djblets/static/djblets/js/extensions/models/extensionModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..631586a45e399d7d84051f1c38f01b6d340385ff
--- /dev/null
+++ b/djblets/static/djblets/js/extensions/models/extensionModel.js
@@ -0,0 +1,25 @@
+/*
+ * Base class for an extension.
+ *
+ * Extensions that deal with JavaScript should subclass this to provide any
+ * initialization code it needs, such as the initialization of hooks.
+ *
+ * Extension instances will have read access to the server-stored settings
+ * for the extension.
+ */
+Djblets.Extension = Backbone.Model.extend({
+    defaults: {
+        id: null,
+        name: null,
+        settings: {}
+    },
+
+    /*
+     * Initializes the extension.
+     *
+     * Subclasses are expected to call the parent initialize.
+     */
+    initialize: function() {
+        this.hooks = [];
+    }
+});
