diff --git a/djblets/extensions/extension.py b/djblets/extensions/extension.py
index 2ce3e3c721f2452121a033262bd85b59f5761395..6a76f99fa907d576d8517df6350ad3388859eb2e 100644
--- a/djblets/extensions/extension.py
+++ b/djblets/extensions/extension.py
@@ -44,6 +44,10 @@ class Extension(object):
     support for settings, adding hooks, and plugging into the administration
     UI.
 
+
+    Configuration
+    -------------
+
     If an extension supports configuration in the UI, it should set
     :py:attr:`is_configurable` to True.
 
@@ -53,14 +57,62 @@ class Extension(object):
     If an extension would like a django admin site for modifying the database,
     it should set :py:attr:`has_admin_site` to True.
 
+
+    Static Media
+    ------------
+
     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.
+    Extensions can define static media bundle for Less/CSS and JavaScript
+    files, which will automatically be compiled, minified, combined, and
+    packaged. An Extension class can define a :py:attr:`css_bundles` and
+    a :py:attr:`js_bundles`. Each is a dictionary mapping bundle names
+    to bundle dictionary. These follow the Django Pipeline bundle format.
+
+    For example:
+
+        class MyExtension(Extension):
+            css_bundles = {
+                'default': {
+                    'source_filenames': ['css/default.css'],
+                    'output_filename': 'css/default.min.css',
+                },
+            }
+
+    ``source_filenames`` is a list of files within the extension module's
+    static/ directory that should be bundled together. When testing against
+    a developer install with ``DEBUG = True``, these files will be individually
+    loaded on the page. However, in a production install, with a properly
+    installed extension package, the compiled bundle file will be loaded
+    instead, offering a file size and download savings.
+
+    ``output_filename`` is optional. If not specified, the bundle name will
+    be used as a base for the filename.
+
+    A bundle name of ``default`` is special. It will be loaded automatically
+    on any page supporting extensions (provided the ``load_extensions_js`` and
+    ``load_extensions_css`` template tags are used).
+
+    Other bundle names can be loaded within a TemplateHook template using
+    ``{% ext_css_bundle extension "bundle-name" %}`` or
+    ``{% ext_js_bundle extension "bundle-name" %}``.
+
+
+    JavaScript extensions
+    ---------------------
+
+    An Extension subclass can define a :py:attr:`js_model_class` attribute
+    naming its JavaScript counterpart. This would be the variable name for the
+    (uninitialized) model for the extension, usually defined in the "default"
+    JavaScript bundle.
+
+    Any page using the ``init_js_extensions`` template tag will automatically
+    initialize these JavaScript extensions, passing the server-stored settings.
+
+
+    Middleware
+    ----------
 
     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
@@ -76,7 +128,9 @@ class Extension(object):
     apps = []
     middleware = []
 
-    js_files = []
+    css_bundles = {}
+    js_bundles = {}
+
     js_model_class = None
 
     def __init__(self, extension_manager):
@@ -110,6 +164,10 @@ class Extension(object):
         return self._admin_urlconf_module
     admin_urlconf = property(_get_admin_urlconf)
 
+    def get_bundle_id(self, name):
+        """Returns the ID for a CSS or JavaScript bundle."""
+        return '%s-%s' % (self.id, name)
+
     def get_js_model_data(self):
         """Returns model data for the Extension instance in JavaScript.
 
diff --git a/djblets/extensions/hooks.py b/djblets/extensions/hooks.py
index 2e59a73a524d968d05a32c316561a70853bed02e..e65422b9d0d690e134e870d210a85b9ff9e08b2a 100644
--- a/djblets/extensions/hooks.py
+++ b/djblets/extensions/hooks.py
@@ -130,7 +130,13 @@ class TemplateHook(ExtensionHook):
         By default, this renders the provided template name to a string
         and returns it.
         """
-        return render_to_string(self.template_name, context)
+        context.push()
+        context['extension'] = self.extension
+
+        try:
+            return render_to_string(self.template_name, context)
+        finally:
+            context.pop()
 
     def applies_to(self, context):
         """Returns whether or not this TemplateHook should be applied given the
diff --git a/djblets/extensions/manager.py b/djblets/extensions/manager.py
index 67c9a4e413ad469626ef7d7221530c04f84072e4..e27a8b7ba4b702ab860dbdbc2d605dc1c2fd6f7b 100644
--- a/djblets/extensions/manager.py
+++ b/djblets/extensions/manager.py
@@ -219,6 +219,7 @@ class ExtensionManager(object):
 
         self._uninstall_extension(extension)
         self._uninit_extension(extension)
+        self._unregister_static_bundles(extension)
         extension.registration.enabled = False
         extension.registration.save()
 
@@ -399,6 +400,8 @@ class ExtensionManager(object):
         # for the admin site will not be generated until it is called.
         self._install_admin_urls(extension)
 
+        self._register_static_bundles(extension)
+
         extension.info.installed = extension.registration.installed
         extension.info.enabled = True
         self._add_to_installed_apps(extension)
@@ -575,6 +578,61 @@ class ExtensionManager(object):
             self.dynamic_urls.add_patterns(
                 extension.admin_site_urlpatterns)
 
+    def _register_static_bundles(self, extension):
+        """Registers the extension's static bundles with Pipeline.
+
+        Each static bundle will appear as an entry in Pipeline. The
+        bundle name and filenames will be changed to include the extension
+        ID for the static file lookups.
+        """
+        def _add_prefix(filename):
+            return '%s/%s' % (extension.id, filename)
+
+        def _add_bundles(pipeline_bundles, extension_bundles, default_dir,
+                         ext):
+            for name, bundle in extension_bundles.iteritems():
+                new_bundle = bundle.copy()
+
+                new_bundle['source_filenames'] = [
+                    'ext/%s' % _add_prefix(filename)
+                    for filename in bundle.get('source_filenames', [])
+                ]
+
+                new_bundle['output_filename'] = _add_prefix(bundle.get(
+                    'output_filename',
+                    '%s/%s.min%s' % (default_dir, name, ext)))
+
+                pipeline_bundles[extension.get_bundle_id(name)] = new_bundle
+
+        if not hasattr(settings, 'PIPELINE_CSS'):
+            settings.PIPELINE_CSS = {}
+
+        if not hasattr(settings, 'PIPELINE_JS'):
+            settings.PIPELINE_JS = {}
+
+        _add_bundles(settings.PIPELINE_CSS, extension.css_bundles,
+                     'css', '.css')
+        _add_bundles(settings.PIPELINE_JS, extension.js_bundles,
+                     'js', '.js')
+
+    def _unregister_static_bundles(self, extension):
+        """Unregisters the extension's static bundles from Pipeline.
+
+        Every static bundle previously registered will be removed.
+        """
+        def _remove_bundles(pipeline_bundles, extension_bundles):
+            for name, bundle in extension_bundles.iteritems():
+                try:
+                    del pipeline_bundles[extension.get_bundle_id(name)]
+                except KeyError:
+                    pass
+
+        if hasattr(settings, 'PIPELINE_CSS'):
+            _remove_bundles(settings.PIPELINE_CSS, extension.css_bundles)
+
+        if hasattr(settings, 'PIPELINE_JS'):
+            _remove_bundles(settings.PIPELINE_JS, extension.js_bundles)
+
     def _init_admin_site(self, extension):
         """Creates and initializes an admin site for an extension.
 
diff --git a/djblets/extensions/packaging.py b/djblets/extensions/packaging.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b6f7b0ff9ad25d4e120f4ba95f5c105c1a663f2
--- /dev/null
+++ b/djblets/extensions/packaging.py
@@ -0,0 +1,156 @@
+import inspect
+import os
+import sys
+
+import pkg_resources
+from distutils.command.build_py import build_py
+from distutils.core import Command
+from django.conf import settings
+from django.core.management import call_command
+
+
+class BuildStaticFiles(Command):
+    """Builds static files for the extension.
+
+    This will build the static media files used by the extension. JavaScript
+    bundles will be minified and versioned. CSS bundles will be processed
+    through lesscss (if using .less files), minified and versioned.
+
+    This must be subclassed by the project offering the extension support.
+    The subclass must provide the extension_entrypoint_group and
+    django_settings_module parameters.
+
+    extension_entrypoint_group is the group name that entry points register
+    into.
+
+    django_settings_module is the Python module path for the project's
+    settings module, for use in the DJANGO_SETTINGS_MODULE environment
+    variable.
+    """
+    description = 'Build static media files'
+    extension_entrypoint_group = None
+    django_settings_module = None
+
+    def initialize_options(self):
+        self.build_lib = None
+
+    def finalize_options(self):
+        self.set_undefined_options('build', ('build_lib', 'build_lib'))
+
+    def run(self):
+        # Prepare to import the project's settings file, and the extension
+        # modules that are being shipped, so we can scan for the bundled
+        # media.
+        old_settings_module = os.environ.get('DJANGO_SETTINGS_MODULE')
+        os.environ['DJANGO_SETTINGS_MODULE'] = self.django_settings_module
+        cwd = os.getcwd()
+        sys.path = [
+            os.path.join(cwd, package_name)
+            for package_name in self.distribution.packages
+        ] + sys.path
+
+        # Set up the common Django settings for the builds.
+        settings.STATICFILES_FINDERS = (
+            'django.contrib.staticfiles.finders.FileSystemFinder',
+        )
+        settings.STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
+        settings.INSTALLED_APPS = [
+            'django.contrib.staticfiles',
+        ]
+        settings.CACHES = {
+            'default': {
+                'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+            },
+        }
+
+        # Load the entry points this package is providing, so we'll know
+        # which extensions to scan.
+        entrypoints = pkg_resources.EntryPoint.parse_map(
+            self.distribution.entry_points,
+            dist=self.distribution)
+
+        extension_entrypoints = \
+            entrypoints.get(self.extension_entrypoint_group)
+        assert extension_entrypoints, 'No extension entry points were defined.'
+
+        # Begin building pipeline bundles for each of the bundles defined
+        # in the extension.
+        for entrypoint_name, entrypoint in extension_entrypoints.iteritems():
+            try:
+                extension = entrypoint.load(require=False)
+            except ImportError:
+                sys.stderr.write('Error loading the extension for entry '
+                                 'point %s\n' % entrypoint_name)
+                raise
+
+            self._build_static_media(extension)
+
+        # Restore the environment, so we don't possibly interfere with
+        # anything else.
+        if old_settings_module is not None:
+            os.environ['DJANGO_SETTINGS_MODULE'] = old_settings_module
+
+        sys.path = sys.path[len(self.distribution.packages):]
+
+    def _build_static_media(self, extension):
+        pipeline_js = {}
+        pipeline_css = {}
+
+        self._add_bundle(pipeline_js, extension.js_bundles, 'js', '.js')
+        self._add_bundle(pipeline_css, extension.css_bundles, 'css', '.css')
+
+        # Get the location of the static/ directory within the module in the
+        # source tree. We're going to use it to look up static files for
+        # input, and as a relative path within the module for the output.
+        module_dir = os.path.dirname(inspect.getmodule(extension).__file__)
+
+        # Set the appropriate settings in order to build these static files.
+        settings.PIPELINE_JS = pipeline_js
+        settings.PIPELINE_CSS = pipeline_css
+        settings.PIPELINE_ENABLED = True
+        settings.STATICFILES_DIRS = [
+            os.path.join(module_dir, 'static')
+        ]
+        settings.STATIC_ROOT = \
+            os.path.join(self.build_lib,
+                         os.path.relpath(os.path.join(module_dir, 'static')))
+
+        # Due to how Pipeline copies and stores its settings, we actually
+        # have to copy over some of these, as they'll be from the original
+        # loaded settings.
+        from pipeline.conf import settings as pipeline_settings
+
+        pipeline_settings.PIPELINE_JS = settings.PIPELINE_JS
+        pipeline_settings.PIPELINE_CSS = settings.PIPELINE_CSS
+        pipeline_settings.PIPELINE_ENABLED = settings.PIPELINE_ENABLED
+        pipeline_settings.PIPELINE_ROOT = settings.STATIC_ROOT
+
+        # Collect and process all static media files.
+        call_command('collectstatic', interactive=False, verbosity=2)
+
+    def _add_bundle(self, pipeline_bundles, extension_bundles, default_dir,
+                    ext):
+        for name, bundle in extension_bundles.iteritems():
+            if 'output_filename' not in bundle:
+                bundle['output_filename'] = \
+                    '%s/%s.min%s' % (default_dir, name, ext)
+
+            pipeline_bundles[name] = bundle
+
+
+class BuildPy(build_py):
+    def run(self):
+        self.run_command('build_static_files')
+        build_py.run(self)
+
+
+def build_extension_cmdclass(build_static_files_cls):
+    """Builds a cmdclass to pass to setup.
+
+    This is passed a subclass of BuildStaticFiles, and returns something
+    that can be passed to setup().
+    """
+    return {
+        'build_static_files': build_static_files_cls,
+        'build_py': BuildPy,
+    }
diff --git a/djblets/extensions/templates/extensions/init_js_extensions.html b/djblets/extensions/templates/extensions/init_js_extensions.html
index bbb4df7455c0ede8a38cc5d73a90b79c2c725b9e..a567a50250b94e84291da487d9dbedf4e2558d38 100644
--- a/djblets/extensions/templates/extensions/init_js_extensions.html
+++ b/djblets/extensions/templates/extensions/init_js_extensions.html
@@ -1,21 +1,16 @@
-{% load djblets_extensions djblets_js %}
+{% load djblets_js %}
 
-{% for extension in extension_manager.get_enabled_extensions %}
-{%  if extension.js_files %}
-{%   for js_file in extension.js_files %}
-<script src="{% ext_static extension js_file %}"></script>
-{%   endfor %}
-{%   if extension.js_model_class %}
+{% if extensions %}
 <script>
+{%  for extension in extensions %}
     new {{extension.js_model_class}}({
-{%    for key, value in extension.get_js_model_data.items %}
+{%   for key, value in extension.get_js_model_data.items %}
         {{key|json_dumps}}: {{value|json_dumps}},
-{%    endfor %}
+{%   endfor %}
         id: '{{extension.id}}',
         name: {{extension.info.name|json_dumps}},
         settings: {{extension.settings|json_dumps}}
     });
+{%  endfor %}
 </script>
-{%   endif %}
-{%  endif %}
-{% endfor %}
+{% endif %}
diff --git a/djblets/extensions/templatetags/djblets_extensions.py b/djblets/extensions/templatetags/djblets_extensions.py
index 617e5c9fbaa69ceb94d831e6ffe889a855663682..17dc1bc2e786646042576bd045db3672769828b0 100644
--- a/djblets/extensions/templatetags/djblets_extensions.py
+++ b/djblets/extensions/templatetags/djblets_extensions.py
@@ -1,5 +1,7 @@
 from django import template
 from django.contrib.staticfiles.templatetags.staticfiles import static
+from pipeline.templatetags.compressed import (CompressedCSSNode,
+                                              CompressedJSNode)
 
 from djblets.extensions.hooks import TemplateHook
 from djblets.extensions.manager import get_extension_managers
@@ -38,20 +40,76 @@ def ext_static(context, extension, path):
     return static('ext/%s/%s' % (extension.id, path))
 
 
+def _render_css_bundle(context, extension, name):
+    return CompressedCSSNode(
+        '"%s"' % extension.get_bundle_id(name)).render(context)
+
+
+def _render_js_bundle(context, extension, name):
+    return CompressedJSNode(
+        '"%s"' % extension.get_bundle_id(name)).render(context)
+
+
+@register.tag
+@basictag(takes_context=True)
+def ext_css_bundle(context, extension, name):
+    """Outputs HTML to import an extension's CSS bundle."""
+    return _render_css_bundle(context, extension, name)
+
+
+@register.tag
+@basictag(takes_context=True)
+def ext_js_bundle(context, extension, name):
+    """Outputs HTML to import an extension's JavaScript bundle."""
+    return _render_js_bundle(context, extension, name)
+
+
+@register.tag
+@basictag(takes_context=True)
+def load_extensions_css(context, extension_manager_key):
+    """Loads all default CSS bundles from all enabled extensions."""
+    for manager in get_extension_managers():
+        if manager.key == extension_manager_key:
+            return ''.join([
+                _render_css_bundle(context, extension, 'default')
+                for extension in manager.get_enabled_extensions()
+                if 'default' in extension.css_bundles
+            ])
+
+    return ''
+
+
+@register.tag
+@basictag(takes_context=True)
+def load_extensions_js(context, extension_manager_key):
+    """Loads all default JavaScript bundles from all enabled extensions."""
+    for manager in get_extension_managers():
+        if manager.key == extension_manager_key:
+            return ''.join([
+                _render_js_bundle(context, extension, 'default')
+                for extension in manager.get_enabled_extensions()
+                if 'default' in extension.js_bundles
+            ])
+
+    return ''
+
+
 @register.inclusion_tag('extensions/init_js_extensions.html',
                         takes_context=True)
-def init_js_extensions(context, key):
+def init_js_extensions(context, extension_manager_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:
+        if manager.key == extension_manager_key:
             return {
-                'extension_manager': manager,
-                'request': context['request'],
-                'MEDIA_URL': context['MEDIA_URL'],
+                'extensions': [
+                    extension
+                    for extension in manager.get_enabled_extensions()
+                    if extension.js_model_class
+                ],
             }
 
     return {}
diff --git a/djblets/extensions/tests.py b/djblets/extensions/tests.py
index ad559b50c235416ec5106280fe4dc4edd79be0a2..7c6b45870f4621ed1c8e6c6e290a13d27d6f5cdc 100644
--- a/djblets/extensions/tests.py
+++ b/djblets/extensions/tests.py
@@ -315,7 +315,17 @@ class ExtensionManagerTest(TestCase):
     def setUp(self):
         class TestExtension(Extension):
             """An empty, dummy extension for testing"""
-            pass
+            css_bundles = {
+                'default': {
+                    'source_filenames': ['test.css'],
+                }
+            }
+
+            js_bundles = {
+                'default': {
+                    'source_filenames': ['test.js'],
+                }
+            }
 
         self.key = 'test_key'
         self.extension_class = TestExtension
@@ -401,6 +411,56 @@ class ExtensionManagerTest(TestCase):
 
         self.assertEqual(len(URLHook.hooks), 0)
 
+    def test_enable_registers_static_bundles(self):
+        """Testing ExtensionManager registers static bundles when enabling extension"""
+        settings.PIPELINE_CSS = {}
+        settings.PIPELINE_JS = {}
+
+        extension = self.extension_class(extension_manager=self.manager)
+        extension = self.manager.enable_extension(self.extension_class.id)
+
+        self.assertEqual(len(settings.PIPELINE_CSS), 1)
+        self.assertEqual(len(settings.PIPELINE_JS), 1)
+
+        key = '%s-default' % extension.id
+        self.assertIn(key, settings.PIPELINE_CSS)
+        self.assertIn(key, settings.PIPELINE_JS)
+
+        css_bundle = settings.PIPELINE_CSS[key]
+        js_bundle = settings.PIPELINE_JS[key]
+
+        self.assertIn('source_filenames', css_bundle)
+        self.assertEqual(css_bundle['source_filenames'],
+                         ['ext/%s/test.css' % extension.id])
+
+        self.assertIn('output_filename', css_bundle)
+        self.assertEqual(css_bundle['output_filename'],
+                         '%s/css/default.min.css' % extension.id)
+
+        self.assertIn('source_filenames', js_bundle)
+        self.assertEqual(js_bundle['source_filenames'],
+                         ['ext/%s/test.js' % extension.id])
+
+        self.assertIn('output_filename', js_bundle)
+        self.assertEqual(js_bundle['output_filename'],
+                         '%s/js/default.min.js' % extension.id)
+
+    def test_disable_unregisters_static_bundles(self):
+        """Testing ExtensionManager unregisters static bundles when disabling extension"""
+        settings.PIPELINE_CSS = {}
+        settings.PIPELINE_JS = {}
+
+        extension = self.extension_class(extension_manager=self.manager)
+        extension = self.manager.enable_extension(self.extension_class.id)
+
+        self.assertEqual(len(settings.PIPELINE_CSS), 1)
+        self.assertEqual(len(settings.PIPELINE_JS), 1)
+
+        self.manager.disable_extension(extension.id)
+
+        self.assertEqual(len(settings.PIPELINE_CSS), 0)
+        self.assertEqual(len(settings.PIPELINE_JS), 0)
+
     def test_extension_list_sync(self):
         """Testing ExtensionManager extension list synchronization cross-process."""
         key = 'extension-list-sync'
