diff --git a/docs/decisions/0015-page-titles-via-helmet.rst b/docs/decisions/0015-page-titles-via-helmet.rst
new file mode 100644
index 00000000..09d4cda5
--- /dev/null
+++ b/docs/decisions/0015-page-titles-via-helmet.rst
@@ -0,0 +1,120 @@
+####################################
+Apps set page titles via react-helmet
+####################################
+
+Status
+======
+
+Proposed
+
+
+Context
+=======
+
+A frontend-base site renders all of its apps under a single ``index.html``
+document with a static ``
`` element. Once the shell is loaded, the
+document title only changes if something inside the React tree explicitly
+updates it.
+
+``frontend-app-authn`` sets a title on each route-level page using
+```` from ``react-helmet``, with a localized message of the form
+``{Page Name} | {siteName}``::
+
+
+
+ {formatMessage(messages['login.page.title'], { siteName: getSiteConfig().siteName })}
+
+
+
+The last title set by any app sticks until it is replaced; and currently, Authn
+is the only app that does this. In practice, after a user signs in, the
+browser tab continues to read "Login | " while they sit on the learner
+dashboard. This is a UX regression.
+
+A consistent, app-owned pattern is needed so that every route updates the
+title and apps don't silently inherit each other's state.
+
+
+Decision
+========
+
+Every route-level page component in an app is responsible for setting the
+document title. Apps do this with ```` from ``react-helmet``,
+following the pattern already established in Authn:
+
+#. The page component renders a ```` block containing a single
+ ```` element.
+#. The title text comes from a localized message with the id pattern
+ ``{page}.page.title`` and a default of ``{Page Name} | {siteName}``.
+#. ``siteName`` is passed as an i18n parameter, sourced from
+ ``getSiteConfig().siteName``, so localizers can re-order the segments
+ without code changes.
+#. The message lives in the same app, next to the page component.
+
+A "route-level page component" means the component a route renders directly
+(``LoginPage``, ``LearnerDashboard``, ``InstructorDashboard``, etc.), not
+shared layouts, slots, or nested widgets. Setting the title at the route
+level keeps ownership unambiguous: exactly one component per visible page
+claims the title.
+
+frontend-base does not own the title and does not re-export Helmet. Each app
+that needs to set a title declares ``react-helmet`` as a dependency.
+
+The static ```` in the site's ``index.html`` remains the fallback for
+the brief period before the React tree mounts. The operator should hard-code
+it to the site name so the fallback is sensible if a page somehow fails to set
+its own.
+
+
+Consequences
+============
+
+Every app that renders user-visible pages adds ``react-helmet`` as a
+dependency (if it doesn't already have one) and ```` blocks to each
+route-level page component, plus a ``{page}.page.title`` message per page.
+
+Pages with dynamic titles (e.g., a course outline page that should read
+" | ") still fit the pattern: the page component renders
+```` after its data is available, with the dynamic value passed
+through ``formatMessage``. Until the data resolves, the previous page's
+title persists; that is acceptable for the short data-loading window and is
+no worse than the current behavior.
+
+Because every app uses the same ``react-helmet`` instance under the hood and
+Helmet's last-mount-wins semantics are well-defined, two pages cannot fight
+over the title within a single navigation.
+
+
+Rejected alternatives
+=====================
+
+A ``usePageTitle()`` hook in frontend-base
+------------------------------------------
+
+frontend-base could export a hook that wraps ``react-helmet`` (or mutates
+``document.title`` directly) so that apps don't import Helmet themselves.
+This was rejected because the wrapper adds an API surface for something
+Helmet already does well, and it would force a refactor of authn's existing
+usage with no behavioral benefit. If many more apps adopt the pattern and a
+wrapper proves valuable, we can revisit.
+
+Setting the title centrally in the shell from route metadata
+------------------------------------------------------------
+
+The shell could read a ``title`` field from each ``RoleRouteObject`` and
+apply it on navigation. This was rejected because titles often depend on
+data only available inside the page component after fetching (course names,
+user names, dashboard counts). A static metadata field can't express that,
+and a function-of-loader-data field re-creates the page component's
+responsibilities one layer up.
+
+Standardizing on ``react-helmet-async``
+---------------------------------------
+
+``react-helmet-async`` is generally recommended over ``react-helmet`` for
+React 18+ projects: it avoids ``act()`` warnings, supports streaming SSR, and
+is actively maintained. We chose ``react-helmet`` for this ADR because it
+matches authn's current usage and frontend-base does not currently SSR.
+Standardizing on ``react-helmet-async`` across all apps is a worthwhile
+follow-up but does not need to block this decision; the pattern in this ADR
+applies identically to either library.
diff --git a/docs/how_tos/migrate-frontend-app.md b/docs/how_tos/migrate-frontend-app.md
index 124c320f..1947ffb6 100644
--- a/docs/how_tos/migrate-frontend-app.md
+++ b/docs/how_tos/migrate-frontend-app.md
@@ -886,6 +886,65 @@ This may require a little interpretation. In spirit, the modules of your app ar
These modules should be unopinionated about the path prefix where they are mounted.
+Set the document title on every route-level page
+================================================
+
+A frontend-base site renders all of its apps inside a single `index.html`, so the document title only changes if a page explicitly updates it. If your app doesn't set a title, the browser tab keeps whatever the previously rendered app set.
+
+Each route-level page component must therefore set the document title using `` from `react-helmet`. In practice this is the small wrapper component the route lazy-loads (typically `Main`), not the inner content component or any shared layout, slot, or nested widget. The pattern is:
+
+1. Add `react-helmet` to the app's `dependencies` if it isn't there already.
+2. Add a localized message per page, with the id pattern `{page}.page.title` and a default of `{Page Name} | {siteName}`. Don't reuse an existing on-page heading message (e.g., the h1/h2 "page title" used in the body): the document title needs `{siteName}` interpolation that would be wrong on a heading.
+3. Render a `` block in the route entry that sets `` from that message, passing `siteName` from `getSiteConfig().siteName` as an i18n parameter.
+
+```jsx
+// src/Main.jsx — the component the route lazy-loads
+import { CurrentAppProvider, getSiteConfig, useIntl } from '@openedx/frontend-base';
+import { Helmet } from 'react-helmet';
+
+import { appId } from './constants';
+import messages from './messages';
+import Dashboard from './containers/Dashboard';
+
+const Main = () => {
+ const { formatMessage } = useIntl();
+ return (
+
+
+
+ {formatMessage(messages['learner.dashboard.page.title'], {
+ siteName: getSiteConfig().siteName,
+ })}
+
+
+
+
+ );
+};
+
+export default Main;
+```
+
+```js
+// src/messages.js
+import { defineMessages } from '@openedx/frontend-base';
+
+export default defineMessages({
+ 'learner.dashboard.page.title': {
+ id: 'learner.dashboard.page.title',
+ defaultMessage: 'Dashboard | {siteName}',
+ description: 'document title for the learner dashboard',
+ },
+});
+```
+
+If the app has nested child routes (for example, a parent route with tabbed sub-routes under it), set the title once at the parent route entry. Per-child titles are optional and follow the same pattern in each child component.
+
+Pages with dynamic titles (for example, a course page that reads `{Course Name} | {siteName}`) follow the same pattern: render `` once the data is available and pass the dynamic value through `formatMessage`. Until the data resolves, the previous page's title persists, which is acceptable for the brief loading window.
+
+See [ADR 0015](../decisions/0015-page-titles-via-helmet.rst) for the full rationale and rejected alternatives.
+
+
Separate runtime styles from the dev harness
============================================