diff --git a/changelog.d/18195.feature b/changelog.d/18195.feature
new file mode 100644
index 0000000000..7f7903bd71
--- /dev/null
+++ b/changelog.d/18195.feature
@@ -0,0 +1 @@
+Add plain-text handling for rich-text topics as per [MSC3765](https://github.com/matrix-org/matrix-spec-proposals/pull/3765).
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 386375d64b..d24b094d45 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -1296,7 +1296,7 @@ class RoomCreationHandler:
             topic = room_config["topic"]
             topic_event, topic_context = await create_event(
                 EventTypes.Topic,
-                {"topic": topic},
+                {"topic": topic, "m.topic": {"m.text": [{"body": topic}]}},
                 True,
             )
             events_to_send.append((topic_event, topic_context))
diff --git a/synapse/handlers/stats.py b/synapse/handlers/stats.py
index 8f90c17060..aa33260809 100644
--- a/synapse/handlers/stats.py
+++ b/synapse/handlers/stats.py
@@ -36,6 +36,7 @@ from synapse.metrics import event_processing_positions
 from synapse.metrics.background_process_metrics import run_as_background_process
 from synapse.storage.databases.main.state_deltas import StateDelta
 from synapse.types import JsonDict
+from synapse.util.events import get_plain_text_topic_from_event_content
 
 if TYPE_CHECKING:
     from synapse.server import HomeServer
@@ -299,7 +300,9 @@ class StatsHandler:
             elif delta.event_type == EventTypes.Name:
                 room_state["name"] = event_content.get("name")
             elif delta.event_type == EventTypes.Topic:
-                room_state["topic"] = event_content.get("topic")
+                room_state["topic"] = get_plain_text_topic_from_event_content(
+                    event_content
+                )
             elif delta.event_type == EventTypes.RoomAvatar:
                 room_state["avatar"] = event_content.get("url")
             elif delta.event_type == EventTypes.CanonicalAlias:
diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py
index 26fbc1a483..b043df68f5 100644
--- a/synapse/storage/databases/main/events.py
+++ b/synapse/storage/databases/main/events.py
@@ -78,6 +78,7 @@ from synapse.types import (
 from synapse.types.handlers import SLIDING_SYNC_DEFAULT_BUMP_EVENT_TYPES
 from synapse.types.state import StateFilter
 from synapse.util import json_encoder
+from synapse.util.events import get_plain_text_topic_from_event_content
 from synapse.util.iterutils import batch_iter, sorted_topologically
 from synapse.util.stringutils import non_null_str_or_none
 
@@ -3102,7 +3103,10 @@ class PersistEventsStore:
     def _store_room_topic_txn(self, txn: LoggingTransaction, event: EventBase) -> None:
         if isinstance(event.content.get("topic"), str):
             self.store_event_search_txn(
-                txn, event, "content.topic", event.content["topic"]
+                txn,
+                event,
+                "content.topic",
+                get_plain_text_topic_from_event_content(event.content) or "",
             )
 
     def _store_room_name_txn(self, txn: LoggingTransaction, event: EventBase) -> None:
diff --git a/synapse/storage/databases/main/stats.py b/synapse/storage/databases/main/stats.py
index 79c49e7fd9..74830b7129 100644
--- a/synapse/storage/databases/main/stats.py
+++ b/synapse/storage/databases/main/stats.py
@@ -48,6 +48,7 @@ from synapse.storage.databases.main.events_worker import InvalidEventError
 from synapse.storage.databases.main.state_deltas import StateDeltasStore
 from synapse.types import JsonDict
 from synapse.util.caches.descriptors import cached
+from synapse.util.events import get_plain_text_topic_from_event_content
 
 if TYPE_CHECKING:
     from synapse.server import HomeServer
@@ -611,7 +612,9 @@ class StatsStore(StateDeltasStore):
             elif event.type == EventTypes.Name:
                 room_state["name"] = event.content.get("name")
             elif event.type == EventTypes.Topic:
-                room_state["topic"] = event.content.get("topic")
+                room_state["topic"] = get_plain_text_topic_from_event_content(
+                    event.content
+                )
             elif event.type == EventTypes.RoomAvatar:
                 room_state["avatar"] = event.content.get("url")
             elif event.type == EventTypes.CanonicalAlias:
diff --git a/synapse/util/events.py b/synapse/util/events.py
index ad9b946578..1e8f53c4e9 100644
--- a/synapse/util/events.py
+++ b/synapse/util/events.py
@@ -13,6 +13,9 @@
 #
 #
 
+from typing import Optional
+
+from synapse.types import JsonDict
 from synapse.util.stringutils import random_string
 
 
@@ -27,3 +30,31 @@ def generate_fake_event_id() -> str:
         A string intended to look like an event ID, but with no actual meaning.
     """
     return "$" + random_string(43)
+
+
+def get_plain_text_topic_from_event_content(content: JsonDict) -> Optional[str]:
+    """
+    Given the content of an m.room.topic event returns the plain text topic
+    representation if any exists.
+
+    Returns:
+        A string representing the plain text topic.
+    """
+    topic = content.get("topic")
+
+    m_topic = content.get("m.topic")
+    if not m_topic:
+        return topic
+
+    m_text = m_topic.get("m.text")
+    if not m_text:
+        return topic
+
+    representation = next(
+        (r for r in m_text if "mimetype" not in r or r["mimetype"] == "text/plain"),
+        None,
+    )
+    if not representation or "body" not in representation:
+        return topic
+
+    return representation["body"]
diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py
index 6c93ead3b8..3ba7584c65 100644
--- a/tests/rest/client/test_rooms.py
+++ b/tests/rest/client/test_rooms.py
@@ -757,6 +757,59 @@ class RoomsCreateTestCase(RoomBase):
         assert channel.resource_usage is not None
         self.assertEqual(37, channel.resource_usage.db_txn_count)
 
+    def test_post_room_topic(self) -> None:
+        # POST with topic key, expect new room id
+        channel = self.make_request("POST", "/createRoom", b'{"topic":"shenanigans"}')
+        self.assertEqual(HTTPStatus.OK, channel.code)
+        self.assertTrue("room_id" in channel.json_body)
+        room_id = channel.json_body["room_id"]
+
+        # GET topic event, expect content from topic key
+        channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,))
+        self.assertEqual(HTTPStatus.OK, channel.code)
+        self.assertEqual(
+            {"topic": "shenanigans", "m.topic": {"m.text": [{"body": "shenanigans"}]}},
+            channel.json_body,
+        )
+
+    def test_post_room_topic_initial_state(self) -> None:
+        # POST with m.room.topic in initial state, expect new room id
+        channel = self.make_request(
+            "POST",
+            "/createRoom",
+            b'{"initial_state":[{"type": "m.room.topic", "content": {"topic": "foobar"}}]}',
+        )
+        self.assertEqual(HTTPStatus.OK, channel.code)
+        self.assertTrue("room_id" in channel.json_body)
+        room_id = channel.json_body["room_id"]
+
+        # GET topic event, expect content from initial state
+        channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,))
+        self.assertEqual(HTTPStatus.OK, channel.code)
+        self.assertEqual(
+            {"topic": "foobar"},
+            channel.json_body,
+        )
+
+    def test_post_room_topic_overriding_initial_state(self) -> None:
+        # POST with m.room.topic in initial state and topic key, expect new room id
+        channel = self.make_request(
+            "POST",
+            "/createRoom",
+            b'{"initial_state":[{"type": "m.room.topic", "content": {"topic": "foobar"}}], "topic":"shenanigans"}',
+        )
+        self.assertEqual(HTTPStatus.OK, channel.code)
+        self.assertTrue("room_id" in channel.json_body)
+        room_id = channel.json_body["room_id"]
+
+        # GET topic event, expect content from topic key
+        channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,))
+        self.assertEqual(HTTPStatus.OK, channel.code)
+        self.assertEqual(
+            {"topic": "shenanigans", "m.topic": {"m.text": [{"body": "shenanigans"}]}},
+            channel.json_body,
+        )
+
     def test_post_room_visibility_key(self) -> None:
         # POST with visibility config key, expect new room id
         channel = self.make_request("POST", "/createRoom", b'{"visibility":"private"}')
diff --git a/tests/util/test_events.py b/tests/util/test_events.py
new file mode 100644
index 0000000000..f6c73261d4
--- /dev/null
+++ b/tests/util/test_events.py
@@ -0,0 +1,97 @@
+#
+# This file is licensed under the Affero General Public License (AGPL) version 3.
+#
+# Copyright (C) 2025 New Vector, Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# See the GNU Affero General Public License for more details:
+# <https://www.gnu.org/licenses/agpl-3.0.html>.
+#
+# Originally licensed under the Apache License, Version 2.0:
+# <http://www.apache.org/licenses/LICENSE-2.0>.
+#
+# [This file includes modifications made by New Vector Limited]
+#
+#
+
+
+from synapse.util.events import get_plain_text_topic_from_event_content
+
+from tests import unittest
+
+
+class EventsTestCase(unittest.TestCase):
+    def test_get_plain_text_topic_no_topic(self) -> None:
+        # No legacy or rich topic, expect None
+        topic = get_plain_text_topic_from_event_content({})
+        self.assertEqual(None, topic)
+
+    def test_get_plain_text_topic_no_rich_topic(self) -> None:
+        # Only legacy topic, expect legacy topic
+        topic = get_plain_text_topic_from_event_content({"topic": "shenanigans"})
+        self.assertEqual("shenanigans", topic)
+
+    def test_get_plain_text_topic_rich_topic_without_representations(self) -> None:
+        # Legacy topic and rich topic without representations, expect legacy topic
+        topic = get_plain_text_topic_from_event_content(
+            {"topic": "shenanigans", "m.topic": {"m.text": []}}
+        )
+        self.assertEqual("shenanigans", topic)
+
+    def test_get_plain_text_topic_rich_topic_without_plain_text_representation(
+        self,
+    ) -> None:
+        # Legacy topic and rich topic without plain text representation, expect legacy topic
+        topic = get_plain_text_topic_from_event_content(
+            {
+                "topic": "shenanigans",
+                "m.topic": {
+                    "m.text": [
+                        {"mimetype": "text/html", "body": "<strong>foobar</strong>"}
+                    ]
+                },
+            }
+        )
+        self.assertEqual("shenanigans", topic)
+
+    def test_get_plain_text_topic_rich_topic_with_plain_text_representation(
+        self,
+    ) -> None:
+        # Legacy topic and rich topic with plain text representation, expect plain text representation
+        topic = get_plain_text_topic_from_event_content(
+            {
+                "topic": "shenanigans",
+                "m.topic": {"m.text": [{"mimetype": "text/plain", "body": "foobar"}]},
+            }
+        )
+        self.assertEqual("foobar", topic)
+
+    def test_get_plain_text_topic_rich_topic_with_implicit_plain_text_representation(
+        self,
+    ) -> None:
+        # Legacy topic and rich topic with implicit plain text representation, expect plain text representation
+        topic = get_plain_text_topic_from_event_content(
+            {"topic": "shenanigans", "m.topic": {"m.text": [{"body": "foobar"}]}}
+        )
+        self.assertEqual("foobar", topic)
+
+    def test_get_plain_text_topic_rich_topic_with_plain_text_and_other_representation(
+        self,
+    ) -> None:
+        # Legacy topic and rich topic with plain text representation, expect plain text representation
+        topic = get_plain_text_topic_from_event_content(
+            {
+                "topic": "shenanigans",
+                "m.topic": {
+                    "m.text": [
+                        {"mimetype": "text/html", "body": "<strong>foobar</strong>"},
+                        {"mimetype": "text/plain", "body": "foobar"},
+                    ]
+                },
+            }
+        )
+        self.assertEqual("foobar", topic)