diff --git a/README.rst b/README.rst
index eeaa003490058931d7a71845b1b67dc4f2fd238f..e2b661144dbe2ff6434b97f4b730cb922a7ed7cc 100644
--- a/README.rst
+++ b/README.rst
@@ -68,6 +68,9 @@ template tags, templates, etc. that can be used by your own codebase.
   Markdown rendering for pages and e-mails, with WYSIWYG editing/rendering
   support
 
+* djblets.pagestate_ -
+  Dynamic injection of content for page templates.
+
 * djblets.pipeline_ -
   Pipeline_ compilers for ES6 JavaScript and optimized LessCSS support
 
diff --git a/djblets/extensions/hooks.py b/djblets/extensions/hooks.py
index 751f9179e039e414b1eab6ed9c77afb5ba92bcb5..68a883db8aba8e9e142eea030024c7494a80fa32 100644
--- a/djblets/extensions/hooks.py
+++ b/djblets/extensions/hooks.py
@@ -19,19 +19,28 @@ from __future__ import annotations
 
 import logging
 import uuid
-from typing import (Any, Callable, Dict, Generic, List, Optional,
-                    Sequence, TYPE_CHECKING, Type, cast)
+from typing import Generic, TYPE_CHECKING, cast
 
 from django.dispatch import Signal
 from django.http import HttpRequest
 from django.template import Context
 from django.template.loader import render_to_string
 from django.urls import URLPattern
+from typing_extensions import final
 
 from djblets.datagrid.grids import Column, DataGrid
 from djblets.extensions.extension import Extension
+from djblets.pagestate.injectors import page_state_injectors
 from djblets.registries.registry import Registry, RegistryItemType
 
+if TYPE_CHECKING:
+    from collections.abc import Callable, Iterator, Mapping, Sequence
+    from typing import Any, List, Optional, Type
+
+    from django.utils.safestring import SafeString
+
+    from djblets.pagestate.state import PageStateData
+
 
 logger = logging.getLogger(__name__)
 
@@ -505,7 +514,7 @@ class TemplateHook(AppliesToURLMixin, ExtensionHook,
     A hook that renders a template at hook points defined in another template.
     """
 
-    _by_name: Dict[str, List[TemplateHook]] = {}
+    _by_name: dict[str, List[TemplateHook]] = {}
 
     ######################
     # Instance variables #
@@ -527,14 +536,14 @@ class TemplateHook(AppliesToURLMixin, ExtensionHook,
     #:
     #: Type:
     #:     dict
-    extra_context: dict
+    extra_context: Mapping[str, Any]
 
     def initialize(
         self,
         name: str,
-        template_name: Optional[str] = None,
-        apply_to: List[str] = [],
-        extra_context: Dict[str, Any] = {},
+        template_name: (str | None) = None,
+        apply_to: Sequence[str] = [],
+        extra_context: Mapping[str, Any] = {},
         *args,
         **kwargs,
     ) -> None:
@@ -568,23 +577,37 @@ class TemplateHook(AppliesToURLMixin, ExtensionHook,
         self.template_name = template_name
         self.extra_context = extra_context
 
-        if name not in self.__class__._by_name:
-            self.__class__._by_name[name] = [self]
+        cls = type(self)
+
+        if name not in cls._by_name:
+            cls._by_name[name] = [self]
         else:
-            self.__class__._by_name[name].append(self)
+            cls._by_name[name].append(self)
 
     def shutdown(self) -> None:
-        self.__class__._by_name[self.name].remove(self)
+        """Shut down the hook.
 
-    def render_to_string(
+        This will unregister it from the hook point.
+        """
+        type(self)._by_name[self.name].remove(self)
+
+    @final
+    def render(
         self,
+        *,
         request: HttpRequest,
         context: Context,
-    ) -> str:
-        """Render the content for the hook.
+    ) -> PageStateData | None:
+        """Perform a render of the template and associated data.
 
-        By default, this renders the provided template name to a string
-        and returns it.
+        This is used when rendering the hook into a template. It will
+        call :py:meth:`render_to_string` and :py:meth:`get_etag`, returning
+        the results.
+
+        Subclasses must not override this.
+
+        Any errors caught during render will be logged and the results
+        ignored.
 
         Args:
             request (django.http.HttpRequest):
@@ -594,8 +617,8 @@ class TemplateHook(AppliesToURLMixin, ExtensionHook,
                 The template render context.
 
         Returns:
-            str:
-            Rendered content to include in the template.
+            djblets.pagestate.state.PageStateData:
+            The rendered data for the template hook.
         """
         context_data = {
             'extension': self.extension,
@@ -606,20 +629,98 @@ class TemplateHook(AppliesToURLMixin, ExtensionHook,
         # Note that context.update implies a push().
         context.update(context_data)
 
+        try:
+            content = self.render_to_string(request=request,
+                                            context=context)
+            etag = self.get_etag(request=request,
+                                 context=context,
+                                 content=content)
+        except Exception as e:
+            logger.exception('Error rendering TemplateHook %r: %s',
+                             self, e,
+                             extra={'request': request})
+
+            return None
+        finally:
+            # Pop the update() we did before.
+            context.pop()
+
+        return {
+            'content': content,
+            'etag': etag,
+        }
+
+    def render_to_string(
+        self,
+        request: HttpRequest,
+        context: Context,
+    ) -> SafeString:
+        """Render the content for the hook.
+
+        By default, this renders the provided template name to a string
+        and returns it.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request.
+
+            context (django.template.Conetxt):
+                The template render context.
+
+        Returns:
+            str:
+            Rendered content to include in the template.
+        """
         assert self.template_name is not None
-        s = render_to_string(template_name=self.template_name,
-                             context=context.flatten(),
-                             request=request)
 
-        context.pop()
+        return render_to_string(template_name=self.template_name,
+                                context=context.flatten(),
+                                request=request)
 
-        return s
+    def get_etag(
+        self,
+        *,
+        request: HttpRequest,
+        context: Context,
+        content: SafeString,
+    ) -> str | None:
+        """Return an ETag representing the state of the content.
+
+        By default, this will return an ETag generated from the rendered
+        content.
+
+        Subclasses can override this to return an ETag based on state
+        generated elsewhere (such as :py:meth:`get_extra_context`), which
+        might be faster than the default way of generating the ETag.
+
+        Proper ETag data ensures that caches are invalidated when the content
+        changes.
+
+        Version Added:
+            5.3
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            context (django.template.Context):
+                The context used to render the template.
+
+            content (django.utils.safestring.SafeString):
+                The rendered content.
+
+        Returns:
+            str:
+            An ETag representing the content, or ``None`` if one wasn't
+            computed.
+        """
+        return None
 
     def get_extra_context(
         self,
         request: HttpRequest,
         context: Context,
-    ) -> Dict[str, Any]:
+    ) -> dict[str, Any]:
         """Return extra context for the hook.
 
         Subclasses can override this to provide additional context
@@ -768,3 +869,65 @@ class BaseRegistryMultiItemHook(Generic[RegistryItemType],
 
         for item in self.items:
             registry.unregister(item)
+
+
+# Inject TemplateHooks into PageStates.
+class _TemplateHookPageStateInjector:
+    """Page state injector for TemplateHooks.
+
+    This allows TemplateHooks to work as page state injectors for named
+    template points.
+    """
+
+    # NOTE: Ideally this would be Final[str], but that ends up being a
+    #       type mismatch with any definition tried in
+    #       PageStateInjectorProtocol (as of 3-August-2025). This may be
+    #       addressed in future typing support.
+    injector_id: str = 'djblets-template-hooks'
+
+    def iter_page_state_data(
+        self,
+        *,
+        point_name: str,
+        request: HttpRequest,
+        context: Context,
+    ) -> Iterator[PageStateData]:
+        """Generate page state data from TemplateHooks.
+
+        This will iterate through every :py:class:`TemplateHook` registered
+        udner the template point name, render the hook's template, and
+        return it for the page.
+
+        Args:
+            point_name (str):
+                The template point name to populate.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            context (django.template.Context):
+                The context for the templates.
+
+        Yields:
+            djblets.pagestate.state.PageStateData:
+            Data for each rendered template hook.
+        """
+        for hook in TemplateHook.by_name(point_name):
+            try:
+                if not hook.applies_to(request):
+                    continue
+            except Exception as e:
+                logger.exception('Error when calling applies_to for '
+                                 'TemplateHook %r: %s',
+                                 hook, e,
+                                 extra={'request': request})
+                continue
+
+            data = hook.render(request=request,
+                               context=context)
+
+            if data is not None:
+                yield data
+
+
+page_state_injectors.register(_TemplateHookPageStateInjector())
diff --git a/djblets/extensions/middleware.py b/djblets/extensions/middleware.py
index 5902ecc69f0711f3d1a783343c467edd84cbd412..b4f3afcde01c96f2d4f62e41e434e02f4b5df79f 100644
--- a/djblets/extensions/middleware.py
+++ b/djblets/extensions/middleware.py
@@ -1,11 +1,24 @@
 """Middleware for extensions."""
 
+from __future__ import annotations
+
 import threading
+from typing import TYPE_CHECKING
 
 from django.conf import settings
 from django.utils.deprecation import MiddlewareMixin
 
 from djblets.extensions.manager import get_extension_managers
+from djblets.pagestate.middleware import PageStateMiddleware
+
+if TYPE_CHECKING:
+    from django.http import HttpRequest, HttpResponseBase
+
+
+needs_page_state_middleware = (
+    'djblets.pagestate.middleware.PageStateMiddleware'
+    not in settings.MIDDLEWARE
+)
 
 
 class ExtensionsMiddleware(MiddlewareMixin):
@@ -31,6 +44,34 @@ class ExtensionsMiddleware(MiddlewareMixin):
         if self.do_expiration_checks:
             self._check_expired()
 
+    def process_response(
+        self,
+        request: HttpRequest,
+        response: HttpResponseBase,
+    ) -> HttpResponseBase:
+        """Process the HTTP response.
+
+        If the application doesn't have
+        :py:class:`~djblets.pagestate.middleware.PageStateMiddleware` in the
+        list of middleware, this will directly pass this through to the
+        middleware to process any ETags for the page.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            response (django.http.HttpResponse):
+                The HTTP response to check and update.
+
+        Returns:
+            django.http.HttpResponseBase:
+            The resulting HTTP response.
+        """
+        if needs_page_state_middleware:
+            response = PageStateMiddleware(lambda request: response)(request)
+
+        return response
+
     def process_view(self, request, view, args, kwargs):
         request._djblets_extensions_kwargs = kwargs
 
diff --git a/djblets/extensions/templatetags/djblets_extensions.py b/djblets/extensions/templatetags/djblets_extensions.py
index e52f479a5fb28e9d22b265d6fb6f6465eea532c6..423e2c42d668509f718d4c2d797bb57724e4d5ef 100644
--- a/djblets/extensions/templatetags/djblets_extensions.py
+++ b/djblets/extensions/templatetags/djblets_extensions.py
@@ -14,6 +14,7 @@ from typing_extensions import TypeAlias
 
 from djblets.extensions.hooks import TemplateHook
 from djblets.extensions.manager import get_extension_managers
+from djblets.pagestate.templatetags.djblets_pagestate import page_hook_point
 
 if TYPE_CHECKING:
     from django.http import HttpRequest
@@ -98,6 +99,10 @@ def template_hook_point(
 ) -> SafeString:
     """Register a place where TemplateHooks can render to.
 
+    This is an alias for :py:func:`{% page_hook_point %}
+    <djblets.pagestate.templatetags.djblets_pagestate.page_hook_point>`.
+    It may be deprecated in the future.
+
     Args:
         context (dict):
             The template rendering context.
@@ -106,37 +111,10 @@ def template_hook_point(
             The name of the CSS bundle to render.
 
     Returns:
-        django.utils.safetext.SafeString:
+        django.utils.safestring.SafeString:
         The rendered HTML.
     """
-    def _render_hooks() -> Iterator[tuple[Union[str, SafeString]]]:
-        request: HttpRequest
-
-        if isinstance(context, RequestContext):
-            request = context.request
-        else:
-            request = context['request']
-
-        for hook in TemplateHook.by_name(name):
-            try:
-                if hook.applies_to(request):
-                    context.push()
-
-                    try:
-                        yield (hook.render_to_string(request, context),)
-                    except Exception as e:
-                        logger.exception('Error rendering TemplateHook %r: %s',
-                                         hook, e,
-                                         extra={'request': request})
-
-                    context.pop()
-            except Exception as e:
-                logger.exception('Error when calling applies_to for '
-                                 'TemplateHook %r: %s',
-                                 hook, e,
-                                 extra={'request': request})
-
-    return format_html_join('', '{0}', _render_hooks())
+    return page_hook_point(context, name)
 
 
 @register.simple_tag(takes_context=True)
diff --git a/djblets/extensions/tests/test_template_hook.py b/djblets/extensions/tests/test_template_hook.py
index ee0b167811ead9687ae2abcf2506cc85b1f7fc0f..d5479f3a11fdfc62bf1ce711e18c409518c6e188 100644
--- a/djblets/extensions/tests/test_template_hook.py
+++ b/djblets/extensions/tests/test_template_hook.py
@@ -2,8 +2,8 @@
 
 from django.template import Context, RequestContext, Template
 from django.test.client import RequestFactory
+from django.urls import ResolverMatch
 from kgb import SpyAgency
-from mock import Mock
 
 from djblets.extensions.extension import Extension
 from djblets.extensions.hooks import TemplateHook
@@ -41,11 +41,15 @@ class TemplateHookTests(SpyAgency, ExtensionTestCaseMixin, TestCase):
     def setUp(self):
         super(TemplateHookTests, self).setUp()
 
-        self.request = Mock()
-        self.request._djblets_extensions_kwargs = {}
-        self.request.path_info = '/'
-        self.request.resolver_match = Mock()
-        self.request.resolver_match.url_name = 'root'
+        request = RequestFactory().get('/')
+        request._djblets_extensions_kwargs = {}  # type: ignore
+        request.resolver_match = ResolverMatch(
+            func=lambda *args, **kwargs: None,
+            args=(),
+            kwargs={},
+            url_name='root')
+
+        self.request = request
 
     def test_hook_added_to_class_by_name(self):
         """Testing TemplateHook registration"""
@@ -86,8 +90,8 @@ class TemplateHookTests(SpyAgency, ExtensionTestCaseMixin, TestCase):
         self.assertTrue(
             self.extension.template_hook_with_applies.applies_to(self.request))
 
-    def test_render_to_string(self):
-        """Testing TemplateHook.render_to_string"""
+    def test_render(self):
+        """Testing TemplateHook.render"""
         hook = TemplateHook(
             self.extension,
             name='test',
@@ -97,12 +101,14 @@ class TemplateHookTests(SpyAgency, ExtensionTestCaseMixin, TestCase):
             })
 
         request = RequestFactory().request()
-        result = hook.render_to_string(request, RequestContext(request, {
-            'classname': 'test',
-        }))
+        result = hook.render(
+            request=request,
+            context=RequestContext(request, {
+                'classname': 'test',
+            }))
 
         self.assertHTMLEqual(
-            result,
+            result['content'],
             '<div class="box-container">'
             ' <div class="box test">'
             '  <div class="box-inner">'
@@ -166,4 +172,4 @@ class TemplateHookTests(SpyAgency, ExtensionTestCaseMixin, TestCase):
 
         self.assertEqual(string, '')
 
-        self.assertTrue(hook.applies_to.called)
+        self.assertSpyCalled(hook.applies_to)
diff --git a/djblets/pagestate/__init__.py b/djblets/pagestate/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9dce61e4e6c62554428719ba78326862bcf68215
--- /dev/null
+++ b/djblets/pagestate/__init__.py
@@ -0,0 +1,5 @@
+"""Dynamic injection of content for page templates.
+
+Version Added:
+    5.3
+"""
diff --git a/djblets/pagestate/injectors.py b/djblets/pagestate/injectors.py
new file mode 100644
index 0000000000000000000000000000000000000000..732fb428760f99b9fb8f93ceb688e59cdc35d18c
--- /dev/null
+++ b/djblets/pagestate/injectors.py
@@ -0,0 +1,79 @@
+"""Injectors for page state.
+
+Version Added:
+    5.3
+"""
+
+from __future__ import annotations
+
+from typing import Protocol, TYPE_CHECKING
+
+from djblets.registries.registry import OrderedRegistry
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from django.http import HttpRequest
+    from django.template import Context
+
+    from djblets.pagestate.state import PageStateData
+
+
+class PageStateInjectorProtocol(Protocol):
+    """Protocol describing page state injectors.
+
+    Classes implementing this protocol can dynamically provide data for
+    page state. They must be registered with :py:attr:`page_state_injectors`.
+
+    Version Added:
+        5.3
+    """
+
+    #: The unique ID of the injector.
+    injector_id: str
+
+    def iter_page_state_data(
+        self,
+        *,
+        point_name: str,
+        request: HttpRequest,
+        context: Context,
+    ) -> Iterator[PageStateData]:
+        """Generate page state data.
+
+        Implementations may yield zero or more
+        :py:class:`~djblets.pagestate.state.PageStateData` for a given
+        page hook point name. These will be made available in the page.
+
+        Args:
+            point_name (str):
+                The page hook point name to populate.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            context (django.template.Context):
+                The context for the templates.
+
+        Yields:
+            djblets.pagestate.state.PageStateData:
+            Data for provide for the page hook point.
+        """
+        ...
+
+
+class PageStateInjectorRegistry(OrderedRegistry[PageStateInjectorProtocol]):
+    """A registry for managing page state injectors.
+
+    Version Added:
+        5.3
+    """
+
+    lookup_attrs = ('injector_id',)
+
+
+#: The main registry for managing page state injectors.
+#:
+#: Version Added:
+#:     5.3
+page_state_injectors = PageStateInjectorRegistry()
diff --git a/djblets/pagestate/middleware.py b/djblets/pagestate/middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..8d301389cda813e37a403a01d67749bb63b1f358
--- /dev/null
+++ b/djblets/pagestate/middleware.py
@@ -0,0 +1,68 @@
+"""Middleware for page states.
+
+Version Added:
+    5.3
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from djblets.pagestate.state import PageState
+from djblets.util.http import encode_etag
+
+if TYPE_CHECKING:
+    from typing import Callable
+
+    from django.http import HttpRequest, HttpResponseBase
+
+
+def PageStateMiddleware(
+    get_response: Callable[[HttpRequest], HttpResponseBase],
+) -> Callable[[HttpRequest], HttpResponseBase]:
+    """Middleware for updating ETags with those from page state injections.
+
+    Version Added:
+        5.3
+
+    Args:
+        get_response (callable):
+            The function for getting a response from a request.
+
+    Returns:
+        callable:
+        The middleware callable for processing the request.
+    """
+    def _middleware(
+        request: HttpRequest,
+    ) -> HttpResponseBase:
+        """Process the HTTP response.
+
+        If the response contains an ETag, any injected ETag data in the
+        page state will be merged into it.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+        Returns:
+            django.http.HttpResponseBase:
+            The resulting HTTP response.
+        """
+        response = get_response(request)
+
+        if ((old_etag := response.headers.get('ETag')) and
+            (page_state := PageState.for_request(request,
+                                                 only_if_exists=True)) and
+            (injected_etag := page_state.get_etag())):
+            # Merge the existing ETag and new one together.
+            new_etag = encode_etag(''.join([
+                old_etag,
+                injected_etag,
+            ]).encode('utf-8'))
+
+            response['ETag'] = new_etag
+
+        return response
+
+    return _middleware
diff --git a/djblets/pagestate/state.py b/djblets/pagestate/state.py
new file mode 100644
index 0000000000000000000000000000000000000000..f5bcad41cb102010383c68bb1dc31c3b98779e26
--- /dev/null
+++ b/djblets/pagestate/state.py
@@ -0,0 +1,317 @@
+"""Page state representations.
+
+Version Added:
+    5.3
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+from typing import TYPE_CHECKING, overload
+
+from django.utils.translation import gettext as _
+from typing_extensions import NotRequired, TypedDict
+
+from djblets.pagestate.injectors import page_state_injectors
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+    from hashlib import _Hash
+    from typing import Literal
+
+    from django.http import HttpRequest
+    from django.template import Context
+    from django.utils.safestring import SafeString
+    from typing_extensions import Self
+
+
+logger = logging.getLogger(__name__)
+
+
+class PageStateData(TypedDict):
+    """Data to inject into a page
+
+    This represents content for the page (as HTML-safe or unsafe text) and an
+    ETag to include with the page response. Both are optional.
+
+    Version Added:
+        5.3
+    """
+
+    #: The content to include in the page.
+    #:
+    #: This can be HTML-safe or unsafe text.
+    content: NotRequired[SafeString | str]
+
+    #: The ETag data to include for the HTTP response.
+    etag: NotRequired[str | None]
+
+
+class PageState:
+    """Additional state used for the dynamic construction of a page.
+
+    This is used to dynamically inject content into a page. Pages can make
+    use of :py:func:`{% page_hook_point %}
+    <djblets.pagestate.templatetags.djblets_pagestate.page_hook_point>`
+    template tags to specify places where content can be injected.
+
+    Content can be injected in two ways:
+
+    1. Manually through calls to :py:meth:inject`.
+
+    2. Dynamically by calling registered
+       :py:mod:`injectors <djblets.pagestate.injectors>`, which take a
+       page hook point name and provide the data to inject.
+
+    Both content for the page and ETags for the response can be injected.
+
+    If the same named page hook point is used in multiple places, or across
+    multiple template renders within a request/response cycle, each point
+    will contain the injected content.
+
+    Version Added:
+        5.3
+    """
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: A mapping of point names to lists of page state data.
+    _data: dict[str, list[PageStateData]]
+
+    #: The current SHA256 for the ETags that have been processed.
+    _etag_sha: _Hash | None
+
+    @overload
+    @classmethod
+    def for_request(
+        cls,
+        request: HttpRequest,
+        *,
+        only_if_exists: Literal[True],
+    ) -> Self | None:
+        ...
+
+    @overload
+    @classmethod
+    def for_request(
+        cls,
+        request: HttpRequest,
+        *,
+        only_if_exists: Literal[False] = ...
+    ) -> Self:
+        ...
+
+    @classmethod
+    def for_request(
+        cls,
+        request: HttpRequest,
+        *,
+        only_if_exists: bool = False,
+    ) -> Self | None:
+        """Return a PageState for a given HTTP request.
+
+        The same instance will be returned every time this is called for the
+        same HTTP request.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            only_if_exists (bool, optional):
+                If set, this will return ``None`` if data doesn't already
+                exist.
+
+                By default, an instance will always be returned.
+
+        Returns:
+            PageState:
+            The page state instance for the request.
+        """
+        try:
+            page_state = getattr(request, '_djblets_page_state')
+        except AttributeError:
+            if only_if_exists:
+                return None
+
+            page_state = cls()
+            setattr(request, '_djblets_page_state', page_state)
+
+        return page_state
+
+    def __init__(self) -> None:
+        """Initialize the page state."""
+        self._data = {}
+        self._etag_sha = None
+
+    def inject(
+        self,
+        point_name: str,
+        data: PageStateData,
+    ) -> None:
+        """Manually inject data into the page.
+
+        This may contain content for the page hook point, and it may
+        contain ETag data to include in the final ETag.
+
+        Args:
+            point_name (str):
+                The page hook point name to inject data into.
+
+            data (PageStateData):
+                The data to inject into the page hook point.
+
+        Raises:
+            ValueError:
+                The page state data was missing a required key or contained
+                an incorrect type.
+        """
+        if not data:
+            return
+
+        if 'content' in data and not isinstance(data['content'], str):
+            raise ValueError(_(
+                'The "content" key must contain a string or SafeString.'
+            ))
+
+        etag = data.get('etag')
+
+        if etag is not None and not isinstance(etag, str):
+            raise ValueError(_(
+                'The "etag" key must be None or a string.'
+            ))
+
+        self._data.setdefault(point_name, []).append(data)
+
+    def clear_injections(
+        self,
+        point_name: (str | None) = None,
+    ) -> None:
+        """Clear injections for one or all page hook points.
+
+        If a point name isn't provided, manual injections will be cleared
+        from all points.
+
+        Args:
+            point_name (str, optional):
+                The optional page hook point name to clear injections from.
+        """
+        if point_name:
+            self._data.pop(point_name, None)
+        else:
+            self._data.clear()
+
+    def get_etag(self) -> str:
+        """Return the current ETag for the page.
+
+        If called while the page is still being rendered, future calls may
+        have a different result.
+
+        Returns:
+            str:
+            The current ETag for the data on the page.
+        """
+        if (etag_sha := self._etag_sha):
+            return etag_sha.hexdigest()
+
+        # There's nothing included for the ETag.
+        return ''
+
+    def iter_content(
+        self,
+        *,
+        point_name: str,
+        request: HttpRequest,
+        context: Context,
+    ) -> Iterator[SafeString | str]:
+        """Iterate through rendered content for a page hook point.
+
+        This will first iterate through all dynamic injectors and then
+        through all manual injections in order.
+
+        Any missing ETags will be generated based on the page content,
+        ensuring that changes in content will cause caches to invalidate.
+
+        Any errors coming from an injector will be logged and the injector
+        skipped.
+
+        Args:
+            point_name (str):
+                The page hook point name to iterate through.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            context (django.template.Context):
+                The context for the template.
+
+        Yields:
+            str or django.utils.safestring.SafeString:
+            Each HTML-safe or unsafe content injected into the page hook
+            point.
+        """
+        etag_sha = self._etag_sha
+
+        for data in self.iter_page_state_data(point_name=point_name,
+                                              request=request,
+                                              context=context):
+            content = data.get('content')
+
+            # Ideally we'll get an ETag back, but if not, we'll use the
+            # page content as data for the ETag hash.
+            etag = data.get('etag') or content
+
+            if etag:
+                if etag_sha is None:
+                    etag_sha = hashlib.sha256()
+                    self._etag_sha = etag_sha
+
+                etag_sha.update(etag.encode('utf-8'))
+
+            if content:
+                yield content
+
+    def iter_page_state_data(
+        self,
+        *,
+        point_name: str,
+        request: HttpRequest,
+        context: Context,
+    ) -> Iterator[PageStateData]:
+        """Iterate through all page state data for a page hook point.
+
+        This will first iterate through all dynamic injectors and then
+        through all manual injections in order.
+
+        Any errors coming from an injector will be logged and the injector
+        skipped.
+
+        Args:
+            point_name (str):
+                The page hook point name to iterate through.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            context (django.template.Context):
+                The context for the template.
+
+        Yields:
+            str or django.utils.safestring.SafeString:
+            Each HTML-safe or unsafe content injected into the template
+            hook point.
+        """
+        for injector in page_state_injectors:
+            try:
+                yield from injector.iter_page_state_data(point_name=point_name,
+                                                         request=request,
+                                                         context=context)
+            except Exception as e:
+                logger.exception('Error iterating through page state data '
+                                 'for injector %r: %s',
+                                 injector, e,
+                                 extra={'request': request})
+
+        yield from self._data.get(point_name, [])
diff --git a/djblets/pagestate/templatetags/__init__.py b/djblets/pagestate/templatetags/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/djblets/pagestate/templatetags/djblets_pagestate.py b/djblets/pagestate/templatetags/djblets_pagestate.py
new file mode 100644
index 0000000000000000000000000000000000000000..13df401d183e21f2db1495e84d96dd863d8541e7
--- /dev/null
+++ b/djblets/pagestate/templatetags/djblets_pagestate.py
@@ -0,0 +1,83 @@
+"""Template tags for working with page states.
+
+Version Added:
+    5.3
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.http import HttpRequest
+from django.template import Library, RequestContext
+from django.utils.html import format_html_join
+from django.utils.safestring import mark_safe
+
+from djblets.pagestate.state import PageState
+
+if TYPE_CHECKING:
+    from django.template import Context
+    from django.utils.safestring import SafeString
+
+
+register = Library()
+
+
+@register.simple_tag(takes_context=True)
+def page_hook_point(
+    context: Context,
+    point_name: str,
+) -> SafeString:
+    """Register a place where content can be injected into.
+
+    This will render any content provided by :py:mod:`page state injectors
+    <djblets.pagestate.injectors>` (such as any registered
+    :py:class:`~djblets.extensions.hooks.TemplateHook`s for extensions) or
+    by manual calls to :py:meth:`PageState.inject()
+    <djblets.pagestate.state.PageState.inject>`.
+
+    Version Added:
+        5.3
+
+    Args:
+        context (django.template.Context):
+            The template rendering context.
+
+        point_name (str):
+            The name of the CSS bundle to render.
+
+    Returns:
+        django.utils.safetext.SafeString:
+        The rendered HTML.
+
+    Example:
+        .. code-block:: html+django
+
+           {% load djblets_pagestate %}
+
+           {% page_hook_point "scripts" %}
+    """
+    request: HttpRequest | None
+
+    if isinstance(context, RequestContext):
+        request = context.request
+    else:
+        request = context.get('request')
+
+    assert request is None or isinstance(request, HttpRequest)
+
+    if request is None:
+        return mark_safe('')
+
+    page_state = PageState.for_request(request)
+
+    return format_html_join(
+        '',
+        '{}',
+        (
+            (page_state_data,)
+            for page_state_data in page_state.iter_content(
+                point_name=point_name,
+                request=request,
+                context=context)
+        ))
diff --git a/djblets/pagestate/tests/__init__.py b/djblets/pagestate/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/djblets/pagestate/tests/test_page_state.py b/djblets/pagestate/tests/test_page_state.py
new file mode 100644
index 0000000000000000000000000000000000000000..733938ef06ce9a7b08ee3b214466f59f2dd1a4d5
--- /dev/null
+++ b/djblets/pagestate/tests/test_page_state.py
@@ -0,0 +1,298 @@
+"""Unit tests for djblets.pagestate.state.PageState.
+
+Version Added:
+    5.3
+"""
+
+from __future__ import annotations
+
+import inspect
+import logging
+from typing import TYPE_CHECKING
+
+from django.template import Context
+from django.test import RequestFactory
+
+from djblets.pagestate.injectors import page_state_injectors
+from djblets.pagestate.state import PageState, logger
+from djblets.testing.testcases import TestCase
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from django.http import HttpRequest
+
+    from djblets.pagestate.state import PageStateData
+
+
+class MyPageStateInjector:
+    """Injector used for unit tests.
+
+    Version Added:
+        5.3
+    """
+
+    injector_id = 'test-injector'
+
+    def iter_page_state_data(
+        self,
+        *,
+        point_name: str,
+        request: HttpRequest,
+        context: Context,
+    ) -> Iterator[PageStateData]:
+        """Generate page state data.
+
+        This generates three sample sets of data:
+
+        1. Content
+        2. Content with ETag
+        3. ETag-only
+
+        Args:
+            point_name (str):
+                The template hook point name to populate.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            context (django.template.Context):
+                The context for the templates.
+
+        Yields:
+            djblets.pagestate.state.PageStateData:
+            Data for each rendered template hook.
+        """
+        yield {
+            'content': 'Test...',
+        }
+
+        if point_name == 'error':
+            raise Exception('oh no!')
+
+        yield {
+            'content': f'Hello from {point_name}',
+            'etag': f'etag-{point_name}',
+        }
+
+        yield {
+            'etag': 'just-etag',
+        }
+
+
+class PageStateTests(TestCase):
+    """Unit tests for PageState.
+
+    Version Added:
+        5.3
+    """
+
+    def test_for_request(self) -> None:
+        """Testing PageState.for_request"""
+        request = RequestFactory().get('/')
+
+        page_state = PageState.for_request(request)
+
+        self.assertIsNotNone(page_state)
+
+        self.assertIs(page_state, PageState.for_request(request))
+
+    def test_for_request_with_only_if_exists_and_exists(self) -> None:
+        """Testing PageState.for_request with only_if_exists=True and
+        exists
+        """
+        request = RequestFactory().get('/')
+
+        page_state = PageState.for_request(request, only_if_exists=True)
+
+        self.assertIsNone(page_state)
+
+    def test_for_request_with_only_if_exists_and_not_exists(self) -> None:
+        """Testing PageState.for_request with only_if_exists=True and does
+        not exist
+        """
+        request = RequestFactory().get('/')
+
+        page_state1 = PageState.for_request(request)
+        page_state2 = PageState.for_request(request, only_if_exists=True)
+
+        self.assertIsNotNone(page_state2)
+        self.assertIs(page_state1, page_state2)
+
+    def test_inject(self) -> None:
+        """Testing PageState.inject"""
+        page_state = PageState()
+        page_state.inject('test-point1', {
+            'content': 'Test 1',
+        })
+        page_state.inject('test-point1', {
+            'content': 'Test 2',
+            'etag': 'my-etag',
+        })
+        page_state.inject('test-point2', {
+            'content': 'Test 3',
+        })
+
+        self.assertEqual(page_state._data, {
+            'test-point1': [
+                {
+                    'content': 'Test 1',
+                },
+                {
+                    'content': 'Test 2',
+                    'etag': 'my-etag',
+                },
+            ],
+            'test-point2': [
+                {
+                    'content': 'Test 3',
+                },
+            ],
+        })
+
+    def test_clear_injections_with_all_points(self) -> None:
+        """Testing PageState.clear_injections with all points"""
+        page_state = PageState()
+        page_state.inject('test-point1', {
+            'content': 'Test 1',
+        })
+        page_state.inject('test-point1', {
+            'content': 'Test 2',
+            'etag': 'my-etag',
+        })
+        page_state.inject('test-point2', {
+            'content': 'Test 3',
+        })
+        page_state.clear_injections()
+
+        self.assertEqual(page_state._data, {})
+
+    def test_clear_injections_with_point_name(self) -> None:
+        """Testing PageState.clear_injections with point name"""
+        page_state = PageState()
+        page_state.inject('test-point1', {
+            'content': 'Test 1',
+        })
+        page_state.inject('test-point1', {
+            'content': 'Test 2',
+            'etag': 'my-etag',
+        })
+        page_state.inject('test-point2', {
+            'content': 'Test 3',
+        })
+        page_state.clear_injections('test-point1')
+
+        self.assertEqual(page_state._data, {
+            'test-point2': [
+                {
+                    'content': 'Test 3',
+                },
+            ],
+        })
+
+    def test_get_etag(self) -> None:
+        """Testing PageState.get_etag"""
+        request = RequestFactory().get('/')
+        context = Context({
+            'request': request,
+        })
+
+        page_state = PageState()
+        page_state.inject('test-point', {
+            'etag': 'my-etag',
+        })
+
+        list(page_state.iter_content(point_name='test-point',
+                                     request=request,
+                                     context=context))
+
+        self.assertEqual(
+            page_state.get_etag(),
+            'f65b90c21028a6de099c2415a9c4e0cb1b97f5c0e3c2d9e4e37672b24ecfa256')
+
+    def test_get_etag_with_no_etags(self) -> None:
+        """Testing PageState.get_etag with no ETags processed"""
+        page_state = PageState()
+        self.assertEqual(page_state.get_etag(), '')
+
+    def test_iter_content(self) -> None:
+        """Testing PageState.iter_content"""
+        request = RequestFactory().get('/')
+        context = Context({
+            'request': request,
+        })
+
+        page_state = PageState()
+        page_state.inject('test-point', {
+            'content': 'Manually injected',
+        })
+
+        injector = MyPageStateInjector()
+
+        try:
+            page_state_injectors.register(injector)
+
+            results_gen = page_state.iter_content(point_name='test-point',
+                                                  request=request,
+                                                  context=context)
+            self.assertTrue(inspect.isgenerator(results_gen))
+
+            results = list(results_gen)
+        finally:
+            page_state_injectors.unregister(injector)
+
+        self.assertEqual(
+            results,
+            [
+                'Test...',
+                'Hello from test-point',
+                'Manually injected',
+            ])
+
+        # Check the ETag.
+        self.assertEqual(
+            page_state.get_etag(),
+            '130fb40ea2247871bc8d25c30b2c84d8a0f41b8854f1a26cacc38e29bd86aa15')
+
+    def test_iter_content_with_injector_failure(self) -> None:
+        """Testing PageState.iter_content with injector failure"""
+        request = RequestFactory().get('/')
+        context = Context({
+            'request': request,
+        })
+
+        page_state = PageState()
+        page_state.inject('error', {
+            'content': 'Manually injected',
+        })
+
+        injector = MyPageStateInjector()
+
+        try:
+            page_state_injectors.register(injector)
+
+            with self.assertLogs(logger, level=logging.DEBUG) as logs_cm:
+                results = list(page_state.iter_content(point_name='error',
+                                                       request=request,
+                                                       context=context))
+        finally:
+            page_state_injectors.unregister(injector)
+
+        self.assertEqual(
+            results,
+            [
+                'Test...',
+                'Manually injected',
+            ])
+
+        # Check the ETag.
+        self.assertEqual(
+            page_state.get_etag(),
+            'be545b7118ba27fc5e13b7e85beaef15007273903288ba64f1513a0044b70d9f')
+
+        self.assertEqual(len(logs_cm.output), 1)
+        self.assertTrue(logs_cm.output[0].startswith(
+            'ERROR:djblets.pagestate.state:Error iterating through page '
+            'state data for injector <djblets.pagestate.tests.test_page_state.'
+            'MyPageStateInjector object at 0x'
+        ))
diff --git a/djblets/pagestate/tests/test_page_state_middleware.py b/djblets/pagestate/tests/test_page_state_middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..3473a45be68d8c53169caa6926eeb8d4ed1a6614
--- /dev/null
+++ b/djblets/pagestate/tests/test_page_state_middleware.py
@@ -0,0 +1,109 @@
+"""Unit tests for djblets.pagestate.middleware.PageStateMiddleware.
+
+Version Added:
+    5.3
+"""
+
+from __future__ import annotations
+
+from django.http import HttpResponse
+from django.template import Context, Template
+from django.test import RequestFactory
+
+from djblets.pagestate.state import PageState
+from djblets.pagestate.middleware import PageStateMiddleware
+from djblets.testing.testcases import TestCase
+
+
+class PageStateMiddlewareTests(TestCase):
+    """Unit tests for PageStateMiddleware.
+
+    Version Added:
+        5.3
+    """
+
+    def test_with_etag_and_injections(self) -> None:
+        """Testing PageStateMiddleware with response ETag, PageState, and
+        injections
+        """
+        request = RequestFactory().get('/')
+
+        page_state = PageState.for_request(request)
+        page_state.inject('test', {
+            'content': 'Test',
+            'etag': 'test',
+        })
+
+        response = HttpResponse(Template(
+            '{% load djblets_pagestate %}'
+            '{% page_hook_point "test" %}'
+        ).render(Context({
+            'request': request,
+        })))
+        response['ETag'] = 'test'
+
+        new_response = PageStateMiddleware(lambda request: response)(request)
+
+        self.assertIs(new_response, response)
+        self.assertEqual(
+            response['ETag'],
+            '8f286193e023cd52b89af86cc9a5588f329e67c4381ec1eb6cc9c49ced471907')
+
+    def test_without_response_etag(self) -> None:
+        """Testing PageStateMiddleware without response ETag"""
+        request = RequestFactory().get('/')
+
+        page_state = PageState.for_request(request)
+        page_state.inject('test', {
+            'content': 'Test',
+            'etag': 'test',
+        })
+
+        response = HttpResponse(Template(
+            '{% load djblets_pagestate %}'
+            '{% page_hook_point "test" %}'
+        ).render(Context({
+            'request': request,
+        })))
+
+        new_response = PageStateMiddleware(lambda request: response)(request)
+
+        self.assertIs(new_response, response)
+        self.assertNotIn('ETag', response)
+
+    def test_without_page_state(self) -> None:
+        """Testing PageStateMiddleware with response ETag and without
+        PageState
+        """
+        request = RequestFactory().get('/')
+
+        response = HttpResponse(Template(
+            '{% load djblets_pagestate %}'
+        ).render(Context({
+            'request': request,
+        })))
+        response['ETag'] = 'test'
+
+        new_response = PageStateMiddleware(lambda request: response)(request)
+
+        self.assertIs(new_response, response)
+        self.assertEqual(response['ETag'], 'test')
+
+    def test_without_injections(self) -> None:
+        """Testing PageStateMiddleware with response ETag and PageState,
+        without injections
+        """
+        request = RequestFactory().get('/')
+
+        response = HttpResponse(Template(
+            '{% load djblets_pagestate %}'
+            '{% page_hook_point "test" %}'
+        ).render(Context({
+            'request': request,
+        })))
+        response['ETag'] = 'test'
+
+        new_response = PageStateMiddleware(lambda request: response)(request)
+
+        self.assertIs(new_response, response)
+        self.assertEqual(response['ETag'], 'test')
diff --git a/djblets/pagestate/tests/test_templatetags.py b/djblets/pagestate/tests/test_templatetags.py
new file mode 100644
index 0000000000000000000000000000000000000000..76403976834675b78a51ab6d59e727dfd730ad1c
--- /dev/null
+++ b/djblets/pagestate/tests/test_templatetags.py
@@ -0,0 +1,54 @@
+"""Unit tests for djblets.pagestate.templatetags.
+
+Version Added:
+    5.3
+"""
+
+from __future__ import annotations
+
+from django.template import Context, Template
+from django.test import RequestFactory
+
+from djblets.pagestate.state import PageState
+from djblets.testing.testcases import TestCase
+
+
+class PageHookPointTests(TestCase):
+    """Testing {% page_hook_point %}.
+
+    Version Added:
+        5.3
+    """
+
+    def test_with_injections(self) -> None:
+        """Testing {% page_hook_point %} with injections"""
+        request = RequestFactory().get('/')
+        page_state = PageState.for_request(request)
+        page_state.inject('test-point', {
+            'content': 'Test 1\n',
+        })
+        page_state.inject('test-point', {
+            'content': 'Test 2\n',
+        })
+
+        result = Template(
+            '{% load djblets_pagestate %}'
+            '{% page_hook_point "test-point" %}'
+        ).render(Context({
+            'request': request,
+        }))
+
+        self.assertEqual(result, 'Test 1\nTest 2\n')
+
+    def test_without_injections(self) -> None:
+        """Testing {% page_hook_point %} without injections"""
+        request = RequestFactory().get('/')
+
+        result = Template(
+            '{% load djblets_pagestate %}'
+            '{% page_hook_point "test-point" %}'
+        ).render(Context({
+            'request': request,
+        }))
+
+        self.assertEqual(result, '')
diff --git a/docs/djblets/coderef/index.rst b/docs/djblets/coderef/index.rst
index fcb95579fc7c3eae53fda6835e656d301ccc2606..005c4e2d496619158b2bd8030f1a80eb1730df23 100644
--- a/docs/djblets/coderef/index.rst
+++ b/docs/djblets/coderef/index.rst
@@ -139,6 +139,21 @@ Database Utilities
    djblets.db.validators
 
 
+.. _coderef-djblets-pagestate:
+
+Dynamic Page State
+==================
+
+.. autosummary::
+   :toctree: python
+
+   djblets.pagestate
+   djblets.pagestate.injectors
+   djblets.pagestate.middleware
+   djblets.pagestate.state
+   djblets.pagestate.templatetags.djblets_pagestate
+
+
 .. _coderef-djblets-extensions:
 
 Extensions
diff --git a/docs/djblets/guides/index.rst b/docs/djblets/guides/index.rst
index c06252131761c4e7d051af0f69040367f4296856..401b203c21e1f3647ae7caa373aae6998329a96d 100644
--- a/docs/djblets/guides/index.rst
+++ b/docs/djblets/guides/index.rst
@@ -11,6 +11,7 @@ Guides
    extensions/index
    features/index
    integrations/index
+   pagestate/index
    privacy/index
    recaptcha/index
    registries/index
diff --git a/docs/djblets/guides/pagestate/index.rst b/docs/djblets/guides/pagestate/index.rst
new file mode 100644
index 0000000000000000000000000000000000000000..60281a7d8300d6b4fb94fa7d4b92136d48f3cb15
--- /dev/null
+++ b/docs/djblets/guides/pagestate/index.rst
@@ -0,0 +1,205 @@
+.. _pagestate-guide:
+
+=======================
+Dynamic Page Injections
+=======================
+
+Extensible products often need to add content to a page, such as ``<script>``
+tags, CSS, notice banners, and navigation items. This needs to be factored
+into a page's ETags so that older content doesn't get stuck in a browser
+cache somewhere.
+
+The :py:mod:`djblets.pagestate` module makes this easy by offering the
+following:
+
+* Template-defined points where data can be injected.
+
+* A method for manually adding content to a template point.
+
+* Injector classes that let calls dynamically generate content for any
+  template points.
+
+We'll walk through how this works.
+
+
+Setting Up
+==========
+
+You'll first need to enable the template tag library and middleware. Add
+the following to your project's :file:`settings.py`:
+
+.. code-block:: python
+
+   INSTALLED_APPS
+       ...,
+       'djblets.pagestate',
+       ...
+   ]
+
+   MIDDLEWARE = [
+       ...
+       'djblets.pagestate.middleware.PageStateMiddleware',
+       ...
+   ]
+
+
+Making a Template Dynamic
+=========================
+
+To make a portion of your template dynamic, simply use the
+:py:func:`{% page_hook_point %} <djblets.pagestate.templatetags.
+djblets_pagestate.page_hook_point>` template tag from the
+:py:mod:`djblets.pagestate.templatetags.djblets_pagestate` template library,
+and give it a name.
+
+For example:
+
+.. code-block:: html+django
+
+   {% load djblets_pagestate %}
+
+   <html>
+    <head>
+     <title>Page Title</title>
+     {% page_hook_point "scripts" %}
+    </head>
+    <body>
+     ...
+     {% page_hook_point "after-content" %}
+    </body>
+   </html>
+
+
+This defines two points:
+
+1. ``scripts``
+2. ``after-content``
+
+Both of these will be replaced with content injected either manually or
+from registered injectors.
+
+
+Manually Injecting Content
+==========================
+
+During your request/response cycle, you can manually inject content into
+a template hook point.
+
+To do this:
+
+1. Fetch the :py:class:`~djblets.pagestate.state.PageState` for the
+   :py:class:`~django.http.HttpRequest`, using
+   :py:meth:`PageState.for_request(request)
+   <djblets.pagestate.pagestate.PageState.for_request>`.
+
+2. Call :py:meth:`PageState.inject() <djblets.pagestate.pagestate.PageState.
+   inject>` with the content and optional ETag to inject.
+
+For example:
+
+.. code-block:: python
+
+   from django.http import HttpRequest, HttpResponse
+   from django.shortcuts import render
+   from django.utils.html import mark_safe
+   from djblets.pagestate.state import PageState
+
+   def my_view(
+       request: HttpRquest,
+   ) -> HttpResponse:
+       page_state = PageState.for_request(request)
+
+       page_state.inject('scripts', {
+           'content': mark_safe('<script>alert("hi!")</script>'),
+       })
+
+       page_state.inject('after-content', {
+           'content': build_some_content_html(),
+           'etag': build_some_content_etag(),
+       });
+
+       return render(request, 'base.html')
+
+
+These will be placed in their respective template hook points.
+
+
+Building Dynamic Injectors
+==========================
+
+You don't have to manually inject content in every view. If you have content
+that's going to be common across pages, you can create an injector.
+
+Injectors are classes that adhere to :py:class:`~djblets.pagestate.
+injectors.PageStateInjectorProtocol`. They register a unique
+:py:attr:`injector_id <djblets.pagestate.injectors.PageStateInjectorProtocol.
+injector_id>` and implement :py:class:`iter_page_state_data()
+<djblets.pagestate.injectors.PageStateInjectorProtocol.iter_page_state_data>`.
+
+They're then registered in the :py:attr:`djblets.pagestate.injectors.
+page_state_injectors`.
+
+For example:
+
+.. code-block:: python
+
+   from collections.abc import Iterator
+
+   from django.http import HttpRequest
+   from django.template import Context
+   from django.utils.html import format_html
+   from djblets.pagestate.injectors import page_state_injectors
+   from djblets.pagestate.state import PageStateData
+
+
+   class MyInjector:
+       injector_id = 'my-injector'
+
+       def iter_page_state_data(
+           self,
+           *,
+           point_name: str,
+           request: HttpRequest,
+           context: Context,
+       ) -> Iterator[PageStateData]
+           if point_name == 'scripts':
+               for i in range(10):
+                   yield {
+                       'content': format_html(
+                           '<script>console.log("i = {}");</script>',
+                           i),
+                       'etag': str(i),
+                   }
+
+   page_state_injectors.register(MyInjector())
+
+
+This simple injector will add a series of ``console.log()`` statements
+for the ``scripts`` template hook point, generating them dynamically based
+on a range of numbers.
+
+In practice, you might use an injector to look up data from a database, a
+:ref:`registry <writing-registries>`, or another source.
+
+
+Cache-Busting with ETags
+========================
+
+When injecting, it's recommended to provide an ETag that differentiates that
+particular piece of content from another that it might generate.
+
+In the above example, we're just using the string version of the number in
+the loop, which is safe if that's the only part of the HTML that would
+change. You might want to include more information than that, such as a
+version identifier for the format of the HTML.
+
+If an ETag isn't specified, the full content will be used for part of the
+ETag.
+
+*If* the resulting HTTP response includes an ETag, then the ETag data
+injected into the page state will be mixed into it, forming a new ETag.
+This ensures that the ETag always changes to reflect any injections.
+
+If the HTTP response does not include an ETag, the ETag data will *not*
+be included, in order to avoid unintentionally caching the full page (which
+may be dynamic in other ways).
