diff --git a/docs/index.rst b/docs/index.rst
index 0b9ee9901d9..11c18c598a8 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -18,3 +18,4 @@ Welcome to QEMU's documentation!
    interop/index
    specs/index
    devel/index
+   qapi/index
diff --git a/docs/qapi/index.rst b/docs/qapi/index.rst
new file mode 100644
index 00000000000..880fd17c709
--- /dev/null
+++ b/docs/qapi/index.rst
@@ -0,0 +1,38 @@
+----------------
+QAPI Domain Test
+----------------
+
+.. qapi:module:: foo-module
+   :no-index:
+
+   This starts a hypothetical module named ``foo-module``, but it
+   doesn't create a cross-reference target and it isn't added to the
+   index.
+
+   Check out the `genindex` for proof that foo-module is not present.
+
+.. qapi:module:: bar-module
+   :no-typesetting:
+
+   This starts a hypothetical module named ``bar-module``, but the
+   contents of the body here will not be rendered in the
+   output. However, any link targets created here or in nested
+   directives will be preserved and functional.
+
+   Check out the `genindex` for proof that bar-module is present in two
+   places! (under both "bar-module" and "QAPI module".)
+
+.. qapi:module:: block-core
+
+   Block core (VM unrelated)
+   =========================
+
+   This starts the documentation section for the ``block-core`` module.
+   All documentation objects that follow belong to the block-core module
+   until another ``qapi:module:`` directive is encountered.
+
+   This directive does not create an entry in the sidebar or the TOC
+   *unless* you create a nested section title within the directive.
+
+   The ``block-core`` module will have two entries in the `genindex`,
+   under both "block-core" and "QAPI module".
diff --git a/docs/sphinx/qapi-domain.py b/docs/sphinx/qapi-domain.py
index 163b9ff21c3..7c5e4407bc1 100644
--- a/docs/sphinx/qapi-domain.py
+++ b/docs/sphinx/qapi-domain.py
@@ -7,21 +7,141 @@
 from typing import (
     TYPE_CHECKING,
     Any,
+    ClassVar,
     Dict,
+    Iterable,
     List,
     Tuple,
+    cast,
 )
 
+from docutils import nodes
+from docutils.parsers.rst import directives
+
+from sphinx import addnodes
 from sphinx.domains import Domain, ObjType
 from sphinx.util import logging
+from sphinx.util.docutils import SphinxDirective, switch_source_input
+from sphinx.util.nodes import make_id, nested_parse_with_titles
 
 
 if TYPE_CHECKING:
+    from docutils.nodes import Element, Node
+
     from sphinx.application import Sphinx
+    from sphinx.util.typing import OptionSpec
 
 logger = logging.getLogger(__name__)
 
 
+def _nested_parse(directive: SphinxDirective, content_node: Element) -> None:
+    """
+    This helper preserves error parsing context across sphinx versions.
+    """
+
+    # necessary so that the child nodes get the right source/line set
+    content_node.document = directive.state.document
+
+    try:
+        # Modern sphinx (6.2.0+) supports proper offsetting for
+        # nested parse error context management
+        nested_parse_with_titles(
+            directive.state,
+            directive.content,
+            content_node,
+            content_offset=directive.content_offset,  # type: ignore[call-arg]
+        )
+    except TypeError:
+        # No content_offset argument. Fall back to SSI method.
+        with switch_source_input(directive.state, directive.content):
+            nested_parse_with_titles(directive.state, directive.content, content_node)
+
+
+class QAPIModule(SphinxDirective):
+    """
+    Directive to mark description of a new module.
+
+    This directive doesn't generate any special formatting, and is just
+    a pass-through for the content body. Named section titles are
+    allowed in the content body.
+
+    Use this directive to associate subsequent definitions with the
+    module they are defined in for purposes of search and QAPI index
+    organization.
+
+    :arg: The name of the module.
+    :opt no-index: Don't add cross-reference targets or index entries.
+    :opt no-typesetting: Don't render the content body (but preserve any
+       cross-reference target IDs in the squelched output.)
+
+    Example::
+
+       .. qapi:module:: block-core
+          :no-index:
+          :no-typesetting:
+
+          Lorem ipsum, dolor sit amet ...
+
+    """
+
+    has_content = True
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = False
+
+    option_spec: ClassVar[OptionSpec] = {
+        # These are universal "Basic" options;
+        # https://www.sphinx-doc.org/en/master/usage/domains/index.html#basic-markup
+        "no-index": directives.flag,
+        "no-typesetting": directives.flag,
+        "no-contents-entry": directives.flag,  # NB: No effect
+        # Deprecated aliases; to be removed in Sphinx 9.0
+        "noindex": directives.flag,
+        "nocontentsentry": directives.flag,  # NB: No effect
+    }
+
+    def run(self) -> List[Node]:
+        modname = self.arguments[0].strip()
+        no_index = "no-index" in self.options or "noindex" in self.options
+
+        self.env.ref_context["qapi:module"] = modname
+
+        content_node: Element = nodes.section()
+        _nested_parse(self, content_node)
+
+        ret: List[Node] = []
+        inode = addnodes.index(entries=[])
+
+        if not no_index:
+            node_id = make_id(self.env, self.state.document, "module", modname)
+            target = nodes.target("", "", ids=[node_id], ismod=True)
+            self.set_source_info(target)
+            self.state.document.note_explicit_target(target)
+
+            indextext = f"QAPI module; {modname}"
+            inode = addnodes.index(
+                entries=[
+                    ("pair", indextext, node_id, "", None),
+                ]
+            )
+            ret.append(inode)
+            content_node.insert(0, target)
+
+        if "no-typesetting" in self.options:
+            if node_ids := [
+                node_id
+                for el in content_node.findall(nodes.Element)
+                for node_id in cast(Iterable[str], el.get("ids", ()))
+            ]:
+                target = nodes.target(ids=node_ids)
+                self.set_source_info(target)
+                ret.append(target)
+        else:
+            ret.extend(content_node.children)
+
+        return ret
+
+
 class QAPIDomain(Domain):
     """QAPI language domain."""
 
@@ -29,7 +149,13 @@ class QAPIDomain(Domain):
     label = "QAPI"
 
     object_types: Dict[str, ObjType] = {}
-    directives = {}
+
+    # Each of these provides a ReST directive,
+    # e.g. .. qapi:module:: block-core
+    directives = {
+        "module": QAPIModule,
+    }
+
     roles = {}
     initial_data: Dict[str, Dict[str, Tuple[Any]]] = {}
     indices = []
