diff --git a/src/App.vue b/src/App.vue
index 742765f74..45e637bdf 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -46,6 +46,7 @@ import ColorScheme from 'docc-render/constants/ColorScheme';
 import Footer from 'docc-render/components/Footer.vue';
 import InitialLoadingPlaceholder from 'docc-render/components/InitialLoadingPlaceholder.vue';
 import { baseNavStickyAnchorId } from 'docc-render/constants/nav';
+import { runCustomPageLoadScripts, runCustomNavigateScripts } from 'docc-render/utils/custom-scripts';
 import { fetchThemeSettings, themeSettingsState, getSetting } from 'docc-render/utils/theme-settings';
 import { objectToCustomProperties } from 'docc-render/utils/themes';
 import { AppTopID } from 'docc-render/constants/AppTopID';
@@ -70,6 +71,7 @@ export default {
     return {
       AppTopID,
       appState: AppStore.state,
+      initialRoutingEventHasOccurred: false,
       fromKeyboard: false,
       isTargetIDE: process.env.VUE_APP_TARGET === 'ide',
       themeSettings: themeSettingsState,
@@ -107,6 +109,30 @@ export default {
     },
   },
   watch: {
+    async $route() {
+      // A routing event has just occurred, which is either the initial page load or a subsequent
+      // navigation. So load any custom scripts that should be run, based on their `run` property,
+      // after this routing event.
+      //
+      // This hook, and (as a result) any appropriate custom scripts for the current routing event,
+      // are called *after* the HTML for the current route has been dynamically added to the DOM.
+      // This means that custom scripts have access to the documentation HTML for the current
+      // topic (or tutorial, etc).
+
+      if (this.initialRoutingEventHasOccurred) {
+        // The initial page load counts as a routing event, so we only want to run "on-navigate"
+        // scripts from the second routing event onward.
+        await runCustomNavigateScripts();
+      } else {
+        // The "on-load" scripts are run here (on the routing hook), not on `created` or `mounted`,
+        // so that the scripts have access to the dynamically-added documentation HTML for the
+        // current topic.
+        await runCustomPageLoadScripts();
+
+        // The next time we enter the routing hook, run the navigation scripts.
+        this.initialRoutingEventHasOccurred = true;
+      }
+    },
     CSSCustomProperties: {
       immediate: true,
       handler(CSSCustomProperties) {
diff --git a/src/utils/custom-scripts.js b/src/utils/custom-scripts.js
new file mode 100644
index 000000000..76ef6aaa8
--- /dev/null
+++ b/src/utils/custom-scripts.js
@@ -0,0 +1,184 @@
+/**
+ * This source file is part of the Swift.org open source project
+ *
+ * Copyright (c) 2021 Apple Inc. and the Swift project authors
+ * Licensed under Apache License v2.0 with Runtime Library Exception
+ *
+ * See https://swift.org/LICENSE.txt for license information
+ * See https://swift.org/CONTRIBUTORS.txt for Swift project authors
+*/
+
+import fetchText from 'docc-render/utils/fetch-text';
+import {
+  copyPresentProperties,
+  copyPropertyIfPresent,
+  has,
+  mustNotHave,
+} from 'docc-render/utils/object-properties';
+import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper';
+
+/**
+ * Returns whether the custom script should be run when the reader navigates to a subpage.
+ * @param {object} customScript
+ * @returns {boolean} Returns whether the custom script has a `run` property with a value of
+ * "on-load" or "on-load-and-navigate". Also returns true if the `run` property is absent.
+ */
+function shouldRunOnPageLoad(customScript) {
+  return !has(customScript, 'run')
+    || customScript.run === 'on-load' || customScript.run === 'on-load-and-navigate';
+}
+
+/**
+ * Returns whether the custom script should be run when the reader navigates to a topic.
+ * @param {object} customScript
+ * @returns {boolean} Returns whether the custom script has a `run` property with a value of
+ * "on-navigate" or "on-load-and-navigate".
+ */
+function shouldRunOnNavigate(customScript) {
+  return has(customScript, 'run')
+    && (customScript.run === 'on-navigate' || customScript.run === 'on-load-and-navigate');
+}
+
+/**
+ * Gets the URL for a local custom script given its name.
+ * @param {string} customScriptName The name of the custom script as spelled in
+ * custom-scripts.json. While the actual filename (in the custom-scripts directory) is always
+ * expected to end in ".js", the name in custom-scripts.json may or may not include the ".js"
+ * extension.
+ * @returns {string} The absolute URL where the script is, accounting for baseURL.
+ * @example
+ * // if baseURL if '/foo'
+ * urlGivenScriptName('hello-world')    // http://localhost:8080/foo/hello-world.js
+ * urlGivenScriptName('hello-world.js') // http://localhost:8080/foo/hello-world.js
+ */
+function urlGivenScriptName(customScriptName) {
+  let scriptNameWithExtension = customScriptName;
+
+  // If the provided name does not already include the ".js" extension, add it.
+  if (customScriptName.slice(-3) !== '.js') {
+    scriptNameWithExtension = `${customScriptName}.js`;
+  }
+
+  return resolveAbsoluteUrl(['', 'custom-scripts', scriptNameWithExtension]);
+}
+
+/**
+ * Add an HTMLScriptElement containing the custom script to the document's head, which runs the
+ * script on page load.
+ * @param {object} customScript The custom script, assuming it should be run on page load.
+ */
+function addScriptElement(customScript) {
+  const scriptElement = document.createElement('script');
+
+  copyPropertyIfPresent('type', customScript, scriptElement);
+
+  if (has(customScript, 'url')) {
+    mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.');
+    mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.');
+
+    scriptElement.src = customScript.url;
+
+    copyPresentProperties(['async', 'defer', 'integrity'], customScript, scriptElement);
+
+    // If `integrity` is set on an external script, then CORS must be enabled as well.
+    if (has(customScript, 'integrity')) {
+      scriptElement.crossOrigin = 'anonymous';
+    }
+  } else if (has(customScript, 'name')) {
+    mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.');
+
+    scriptElement.src = urlGivenScriptName(customScript.name);
+
+    copyPresentProperties(['async', 'defer', 'integrity'], customScript, scriptElement);
+  } else if (has(customScript, 'code')) {
+    mustNotHave(customScript, 'async', 'Inline script cannot be `async`.');
+    mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.');
+    mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.');
+
+    scriptElement.innerHTML = customScript.code;
+  } else {
+    throw new Error('Custom script does not have `url`, `name`, or `code` properties.');
+  }
+
+  document.head.appendChild(scriptElement);
+}
+
+/**
+ * Run the custom script using `eval`. Useful for running a custom script anytime after page load,
+ * namely when the reader navigates to a subpage.
+ * @param {object} customScript The custom script, assuming it should be run on navigate.
+ */
+async function evalScript(customScript) {
+  let codeToEval;
+
+  if (has(customScript, 'url')) {
+    mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.');
+    mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.');
+
+    if (has(customScript, 'integrity')) {
+      // External script with integrity. Must also use CORS.
+      codeToEval = await fetchText(customScript.url, {
+        integrity: customScript.integrity,
+        crossOrigin: 'anonymous',
+      });
+    } else {
+      // External script without integrity.
+      codeToEval = await fetchText(customScript.url);
+    }
+  } else if (has(customScript, 'name')) {
+    mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.');
+
+    const url = urlGivenScriptName(customScript.name);
+
+    if (has(customScript, 'integrity')) {
+      // Local script with integrity. Do not use CORS.
+      codeToEval = await fetchText(url, { integrity: customScript.integrity });
+    } else {
+      // Local script without integrity.
+      codeToEval = await fetchText(url);
+    }
+  } else if (has(customScript, 'code')) {
+    mustNotHave(customScript, 'async', 'Inline script cannot be `async`.');
+    mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.');
+    mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.');
+
+    codeToEval = customScript.code;
+  } else {
+    throw new Error('Custom script does not have `url`, `name`, or `code` properties.');
+  }
+
+  // eslint-disable-next-line no-eval
+  eval(codeToEval);
+}
+
+/**
+ * Run all custom scripts that pass the `predicate` using the `executor`.
+ * @param {(customScript: object) => boolean} predicate
+ * @param {(customScript: object) => void} executor
+ * @returns {Promise<void>}
+ */
+async function runCustomScripts(predicate, executor) {
+  const customScriptsFileName = 'custom-scripts.json';
+  const url = resolveAbsoluteUrl(`/${customScriptsFileName}`);
+
+  const response = await fetch(url);
+  if (!response.ok) {
+    // If the file is absent, fail silently.
+    return;
+  }
+
+  const customScripts = await response.json();
+  if (!Array.isArray(customScripts)) {
+    throw new Error(`Content of ${customScriptsFileName} should be an array.`);
+  }
+
+  customScripts.filter(predicate).forEach(executor);
+}
+
+export async function runCustomPageLoadScripts() {
+  await runCustomScripts(shouldRunOnPageLoad, addScriptElement);
+}
+
+export async function runCustomNavigateScripts() {
+  await runCustomScripts(shouldRunOnNavigate, evalScript);
+}
diff --git a/src/utils/fetch-text.js b/src/utils/fetch-text.js
new file mode 100644
index 000000000..5ad5da558
--- /dev/null
+++ b/src/utils/fetch-text.js
@@ -0,0 +1,23 @@
+/**
+ * This source file is part of the Swift.org open source project
+ *
+ * Copyright (c) 2021 Apple Inc. and the Swift project authors
+ * Licensed under Apache License v2.0 with Runtime Library Exception
+ *
+ * See https://swift.org/LICENSE.txt for license information
+ * See https://swift.org/CONTRIBUTORS.txt for Swift project authors
+*/
+
+import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper';
+
+/**
+ * Fetch the contents of a file as text.
+ * @param {string} filepath The file path.
+ * @param {RequestInit?} options Optional request settings.
+ * @returns {Promise<string>} The text contents of the file.
+ */
+export default async function fetchText(filepath, options) {
+  const url = resolveAbsoluteUrl(filepath);
+  return fetch(url, options)
+    .then(r => r.text());
+}
diff --git a/src/utils/object-properties.js b/src/utils/object-properties.js
new file mode 100644
index 000000000..ed44cfdd6
--- /dev/null
+++ b/src/utils/object-properties.js
@@ -0,0 +1,50 @@
+/**
+ * This source file is part of the Swift.org open source project
+ *
+ * Copyright (c) 2021 Apple Inc. and the Swift project authors
+ * Licensed under Apache License v2.0 with Runtime Library Exception
+ *
+ * See https://swift.org/LICENSE.txt for license information
+ * See https://swift.org/CONTRIBUTORS.txt for Swift project authors
+*/
+
+/* eslint-disable */
+
+/** Convenient shorthand for `Object.hasOwn`. */
+export const has = Object.hasOwn;
+/**
+ * Copies source.property, if it exists, to destination.property.
+ * @param {string} property
+ * @param {object} source
+ * @param {object} destination
+ */
+export function copyPropertyIfPresent(property, source, destination) {
+  if (has(source, property)) {
+    // eslint-disable-next-line no-param-reassign
+    destination[property] = source[property];
+  }
+}
+
+/**
+ * Copies all specified properties present in the source to the destination.
+ * @param {string[]} properties
+ * @param {object} source
+ * @param {object} destination
+ */
+export function copyPresentProperties(properties, source, destination) {
+  properties.forEach((property) => {
+    copyPropertyIfPresent(property, source, destination);
+  });
+}
+
+/**
+ * Throws an error if `object` has the property `property`.
+ * @param {object} object
+ * @param {string} property
+ * @param {string} errorMessage
+ */
+export function mustNotHave(object, property, errorMessage) {
+  if (has(object, property)) {
+    throw new Error(errorMessage);
+  }
+}
diff --git a/src/utils/theme-settings.js b/src/utils/theme-settings.js
index 22e45091d..57a96749f 100644
--- a/src/utils/theme-settings.js
+++ b/src/utils/theme-settings.js
@@ -23,7 +23,7 @@ export const themeSettingsState = {
 export const { baseUrl } = window;
 
 /**
- * Method to fetch the theme settings and store in local module state.
+ * Fetches the theme settings and store in local module state.
  * Method is called before Vue boots in `main.js`.
  * @return {Promise<{}>}
  */
diff --git a/tests/unit/App.spec.js b/tests/unit/App.spec.js
index 61dc9b7a0..cc16bccc1 100644
--- a/tests/unit/App.spec.js
+++ b/tests/unit/App.spec.js
@@ -23,10 +23,17 @@ jest.mock('docc-render/utils/theme-settings', () => ({
   getSetting: jest.fn(() => {}),
 }));
 
+jest.mock('docc-render/utils/custom-scripts', () => ({
+  runCustomPageLoadScripts: jest.fn(),
+}));
+
 let App;
+
 let fetchThemeSettings = jest.fn();
 let getSetting = jest.fn(() => {});
 
+let runCustomPageLoadScripts = jest.fn();
+
 const matchMedia = {
   matches: false,
   addListener: jest.fn(),
@@ -92,6 +99,7 @@ describe('App', () => {
     /* eslint-disable global-require */
     App = require('docc-render/App.vue').default;
     ({ fetchThemeSettings } = require('docc-render/utils/theme-settings'));
+    ({ runCustomPageLoadScripts } = require('docc-render/utils/custom-scripts'));
 
     setThemeSetting({});
     window.matchMedia = jest.fn().mockReturnValue(matchMedia);
@@ -244,6 +252,12 @@ describe('App', () => {
     expect(wrapper.find(`#${AppTopID}`).exists()).toBe(true);
   });
 
+  it('does not load "on-load" scripts immediately', () => {
+    // If "on-load" scripts are run immediately after creating or mounting the app, they will not
+    // have access to the dynamic documentation HTML for the initial route.
+    expect(runCustomPageLoadScripts).toHaveBeenCalledTimes(0);
+  });
+
   describe('Custom CSS Properties', () => {
     beforeEach(() => {
       setThemeSetting(LightDarkModeCSSSettings);
diff --git a/tests/unit/utils/custom-scripts.spec.js b/tests/unit/utils/custom-scripts.spec.js
new file mode 100644
index 000000000..4f7dd0723
--- /dev/null
+++ b/tests/unit/utils/custom-scripts.spec.js
@@ -0,0 +1,149 @@
+/**
+ * This source file is part of the Swift.org open source project
+ *
+ * Copyright (c) 2022 Apple Inc. and the Swift project authors
+ * Licensed under Apache License v2.0 with Runtime Library Exception
+ *
+ * See https://swift.org/LICENSE.txt for license information
+ * See https://swift.org/CONTRIBUTORS.txt for Swift project authors
+*/
+
+/* eslint-disable no-eval */
+
+let runCustomPageLoadScripts;
+let runCustomNavigateScripts;
+
+let jsonMock;
+let fetchMock;
+
+const textMock = jest.fn().mockResolvedValue('');
+
+const createElementMock = jest.fn(document.createElement);
+document.createElement = createElementMock;
+
+const evalMock = jest.fn(eval);
+window.eval = evalMock;
+
+/**
+ * Sets the custom-scripts.json array fetched by the fetchMock.
+ * @param {object[]} customScripts
+ */
+function setCustomScripts(customScripts) {
+  // The jsonMock is different for each test, so it must be reset.
+  jsonMock = jest.fn().mockResolvedValue(customScripts);
+
+  // The first call to the fetch function on each test will be to fetch custom-scripts.json. That's
+  // what the jsonMock is for. Any subsequent calls to fetch will be in the
+  // runCustomNavigateScripts tests, to fetch the contents of each script file.
+  fetchMock = jest.fn()
+    .mockResolvedValueOnce({
+      ok: true,
+      json: jsonMock,
+    }).mockResolvedValue({
+      ok: true,
+      text: textMock,
+    });
+
+  window.fetch = fetchMock;
+}
+
+describe('custom-scripts', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    jest.resetModules();
+    // eslint-disable-next-line global-require
+    ({ runCustomPageLoadScripts, runCustomNavigateScripts } = require('@/utils/custom-scripts'));
+  });
+
+  describe('runCustomPageLoadScripts', () => {
+    it('creates a script element for each explicit or implicit "on-load" script', async () => {
+      setCustomScripts([
+        {
+          url: 'https://www.example.js',
+          async: true,
+          run: 'on-load',
+        },
+        { name: 'my-local-script' },
+      ]);
+
+      await runCustomPageLoadScripts();
+
+      expect(createElementMock).toHaveBeenCalledTimes(2);
+    });
+
+    it('runs "on-load-and-navigate" scripts as well', async () => {
+      setCustomScripts([
+        {
+          name: 'my-local-script.js',
+          run: 'on-load-and-navigate',
+        },
+      ]);
+
+      await runCustomPageLoadScripts();
+
+      expect(createElementMock).toHaveBeenCalledTimes(1);
+    });
+
+    it('does not run "on-navigate" scripts', async () => {
+      setCustomScripts([
+        {
+          name: 'my-local-script',
+          run: 'on-navigate',
+        },
+      ]);
+
+      await runCustomPageLoadScripts();
+
+      expect(createElementMock).toHaveBeenCalledTimes(0);
+    });
+  });
+
+  describe('runCustomNavigateScripts', () => {
+    it('runs "on-navigate" and "on-load-and-navigate" scripts', async () => {
+      setCustomScripts([
+        {
+          name: 'script1.js',
+          run: 'on-navigate',
+        },
+        {
+          name: 'script2',
+          run: 'on-load-and-navigate',
+        },
+        {
+          name: 'script3.js',
+          run: 'on-load-and-navigate',
+        },
+      ]);
+
+      await runCustomNavigateScripts();
+
+      // Unclear why this is necessary for runCustomNavigateScripts, especially since `await`ing
+      // runCustomPageLoadScripts works fine.
+      await new Promise(process.nextTick);
+
+      expect(evalMock).toHaveBeenCalledTimes(3);
+    });
+
+    it('does not create script elements', async () => {
+      setCustomScripts([{
+        name: 'my_script.js',
+        run: 'on-navigate',
+      }]);
+
+      await runCustomNavigateScripts();
+      await new Promise(process.nextTick);
+
+      expect(createElementMock).toHaveBeenCalledTimes(0);
+    });
+
+    it('does not run scripts without a `run` property', async () => {
+      setCustomScripts([{ name: 'my-script' }]);
+
+      await runCustomNavigateScripts();
+      await new Promise(process.nextTick);
+
+      expect(evalMock).toHaveBeenCalledTimes(0);
+    });
+  });
+});