diff --git a/.changeset/tall-eyes-vanish.md b/.changeset/tall-eyes-vanish.md
new file mode 100644
index 000000000000..06b7f62696a2
--- /dev/null
+++ b/.changeset/tall-eyes-vanish.md
@@ -0,0 +1,51 @@
+---
+'astro': minor
+---
+
+Integrations can add new `client:` directives through the `astro:config:setup` hook's `addClientDirective()` API. To enable this API, the user needs to set `experimental.customClientDirectives` to `true` in their config.
+
+```js
+import { defineConfig } from 'astro/config';
+import onClickDirective from 'astro-click-directive';
+
+export default defineConfig({
+  integrations: [onClickDirective()],
+  experimental: {
+    customClientDirectives: true
+  }
+});
+```
+
+```js
+export default function onClickDirective() {
+  return {
+    hooks: {
+      'astro:config:setup': ({ addClientDirective }) => {
+        addClientDirective({
+          name: 'click',
+          entrypoint: 'astro-click-directive/click.js'
+        });
+      },
+    }
+  }
+}
+```
+
+```astro
+<Counter client:click />
+```
+
+The client directive file (e.g. `astro-click-directive/click.js`) should export a function of type `ClientDirective`:
+
+```ts
+import type { ClientDirective } from 'astro'
+
+const clickDirective: ClientDirective = (load, opts, el) => {
+  window.addEventListener('click', async () => {
+    const hydrate = await load()
+    await hydrate()
+  }, { once: true })
+}
+
+export default clickDirective
+```
diff --git a/packages/astro/astro-jsx.d.ts b/packages/astro/astro-jsx.d.ts
index 9f89b8dd066d..c3b28b36a06a 100644
--- a/packages/astro/astro-jsx.d.ts
+++ b/packages/astro/astro-jsx.d.ts
@@ -18,12 +18,13 @@ declare namespace astroHTML.JSX {
 		children: {};
 	}
 
-	interface IntrinsicAttributes extends AstroBuiltinProps, AstroBuiltinAttributes {
+	interface IntrinsicAttributes extends AstroBuiltinProps, AstroBuiltinAttributes, AstroClientDirectives {
 		slot?: string;
 		children?: Children;
 	}
 
 	type AstroBuiltinProps = import('./dist/@types/astro.js').AstroBuiltinProps;
+	type AstroClientDirectives = import('./dist/@types/astro.js').AstroClientDirectives;
 	type AstroBuiltinAttributes = import('./dist/@types/astro.js').AstroBuiltinAttributes;
 	type AstroDefineVarsAttribute = import('./dist/@types/astro.js').AstroDefineVarsAttribute;
 	type AstroScriptAttributes = import('./dist/@types/astro.js').AstroScriptAttributes &
diff --git a/packages/astro/e2e/custom-client-directives.test.js b/packages/astro/e2e/custom-client-directives.test.js
new file mode 100644
index 000000000000..fec5ef9a1104
--- /dev/null
+++ b/packages/astro/e2e/custom-client-directives.test.js
@@ -0,0 +1,92 @@
+import { expect } from '@playwright/test';
+import { testFactory, waitForHydrate } from './test-utils.js';
+import testAdapter from '../test/test-adapter.js';
+
+const test = testFactory({
+	root: './fixtures/custom-client-directives/',
+});
+
+test.describe('Custom Client Directives - dev', () => {
+	let devServer;
+
+	test.beforeAll(async ({ astro }) => {
+		devServer = await astro.startDevServer();
+	});
+
+	test.afterAll(async () => {
+		await devServer.stop();
+	});
+
+	testClientDirectivesShared();
+});
+
+test.describe('Custom Client Directives - build static', () => {
+	let previewServer;
+
+	test.beforeAll(async ({ astro }) => {
+		await astro.build();
+		previewServer = await astro.preview();
+	});
+
+	test.afterAll(async () => {
+		await previewServer.stop();
+	});
+
+	testClientDirectivesShared();
+});
+
+test.describe('Custom Client Directives - build server', () => {
+	let previewServer;
+
+	test.beforeAll(async ({ astro }) => {
+		await astro.build({
+			adapter: testAdapter(),
+		});
+		previewServer = await astro.preview();
+	});
+
+	test.afterAll(async () => {
+		await previewServer.stop();
+	});
+
+	testClientDirectivesShared();
+});
+
+function testClientDirectivesShared() {
+	test('client:click should work', async ({ astro, page }) => {
+		await page.goto(astro.resolveUrl('/'));
+
+		const incrementBtn = page.locator('#client-click .increment');
+		const counterValue = page.locator('#client-click pre');
+
+		await expect(counterValue).toHaveText('0');
+
+		// Component only hydrates on first click
+		await Promise.all([waitForHydrate(page, counterValue), incrementBtn.click()]);
+
+		// Since first click only triggers hydration, this should stay 0
+		await expect(counterValue).toHaveText('0');
+		await incrementBtn.click();
+		// Hydrated, this should be 1
+		await expect(counterValue).toHaveText('1');
+	});
+
+	test('client:password should work', async ({ astro, page }) => {
+		await page.goto(astro.resolveUrl('/'));
+
+		const incrementBtn = page.locator('#client-password .increment');
+		const counterValue = page.locator('#client-password pre');
+
+		await expect(counterValue).toHaveText('0');
+		await incrementBtn.click();
+		// Not hydrated, so this should stay 0
+		await expect(counterValue).toHaveText('0');
+
+		// Type super cool password to activate password!
+		await Promise.all([waitForHydrate(page, counterValue), page.keyboard.type('hunter2')]);
+
+		await incrementBtn.click();
+		// Hydrated, this should be 1
+		await expect(counterValue).toHaveText('1');
+	});
+}
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/astro.config.mjs b/packages/astro/e2e/fixtures/custom-client-directives/astro.config.mjs
new file mode 100644
index 000000000000..451c7ddd8ad3
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/astro.config.mjs
@@ -0,0 +1,38 @@
+import { defineConfig } from 'astro/config';
+import react from "@astrojs/react";
+import { fileURLToPath } from 'url';
+
+export default defineConfig({
+  integrations: [astroClientClickDirective(), astroClientPasswordDirective(), react()],
+  experimental: {
+    customClientDirectives: true
+  }
+});
+
+function astroClientClickDirective() {
+  return {
+    name: 'astro-client-click',
+    hooks: {
+      'astro:config:setup': (opts) => {
+        opts.addClientDirective({
+          name: 'click',
+          entrypoint: fileURLToPath(new URL('./client-click.js', import.meta.url))
+        });
+      }
+    }
+  };
+}
+
+function astroClientPasswordDirective() {
+  return {
+    name: 'astro-client-click',
+    hooks: {
+      'astro:config:setup': (opts) => {
+        opts.addClientDirective({
+          name: 'password',
+          entrypoint: fileURLToPath(new URL('./client-password.js', import.meta.url))
+        });
+      }
+    }
+  };
+}
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/client-click.js b/packages/astro/e2e/fixtures/custom-client-directives/client-click.js
new file mode 100644
index 000000000000..a2866be78258
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/client-click.js
@@ -0,0 +1,7 @@
+// Hydrate on first click on the window
+export default (load) => {
+  window.addEventListener('click', async () => {
+    const hydrate = await load()
+    await hydrate()
+  }, { once: true })
+}
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/client-password.js b/packages/astro/e2e/fixtures/custom-client-directives/client-password.js
new file mode 100644
index 000000000000..36a4939340f7
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/client-password.js
@@ -0,0 +1,21 @@
+// Hydrate when the user types the correct password
+export default (load, options) => {
+  const password = options.value
+  let consecutiveMatch = 0
+
+  const handleKeydown = async (e) => {
+    if (e.key === password[consecutiveMatch]) {
+      consecutiveMatch++
+    } else {
+      consecutiveMatch = 0
+    }
+
+    if (consecutiveMatch === password.length) {
+      window.removeEventListener('keydown', handleKeydown)
+      const hydrate = await load()
+      await hydrate()
+    }
+  }
+  
+  window.addEventListener('keydown', handleKeydown)
+}
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/package.json b/packages/astro/e2e/fixtures/custom-client-directives/package.json
new file mode 100644
index 000000000000..ee1d8ec533c6
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "@test/custom-client-directives",
+  "version": "0.0.0",
+  "private": true,
+  "dependencies": {
+    "@astrojs/react": "workspace:*",
+    "astro": "workspace:*",
+    "react": "^18.0.0",
+    "react-dom": "^18.0.0"
+  }
+}
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/src/client-directives-types.d.ts b/packages/astro/e2e/fixtures/custom-client-directives/src/client-directives-types.d.ts
new file mode 100644
index 000000000000..07399f7bb09c
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/src/client-directives-types.d.ts
@@ -0,0 +1,9 @@
+declare module 'astro' {
+  interface AstroClientDirectives {
+    'client:click'?: boolean
+    'client:password'?: string
+  }
+}
+
+// Make d.ts a module to similate common packaging setups where the entry `index.d.ts` would augment the types
+export {}
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/src/components/Counter.jsx b/packages/astro/e2e/fixtures/custom-client-directives/src/components/Counter.jsx
new file mode 100644
index 000000000000..9d2212b0cae8
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/src/components/Counter.jsx
@@ -0,0 +1,18 @@
+import React, { useState } from 'react';
+
+export default function Counter({ children, count: initialCount = 0, id }) {
+	const [count, setCount] = useState(initialCount);
+	const add = () => setCount((i) => i + 1);
+	const subtract = () => setCount((i) => i - 1);
+
+	return (
+		<>
+			<div id={id} className="counter">
+				<button className="decrement" onClick={subtract}>-</button>
+				<pre>{count}</pre>
+				<button className="increment" onClick={add}>+</button>
+			</div>
+			<div className="counter-message">{children}</div>
+		</>
+	);
+}
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/src/pages/index.astro b/packages/astro/e2e/fixtures/custom-client-directives/src/pages/index.astro
new file mode 100644
index 000000000000..05c28b109e1c
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/src/pages/index.astro
@@ -0,0 +1,10 @@
+---
+import Counter from '../components/Counter.jsx';
+---
+
+<html>
+<body>
+  <Counter id="client-click" client:click>client:click</Counter>
+  <Counter id="client-password" client:password="hunter2">client:password</Counter>
+</body>
+</html>
\ No newline at end of file
diff --git a/packages/astro/e2e/fixtures/custom-client-directives/tsconfig.json b/packages/astro/e2e/fixtures/custom-client-directives/tsconfig.json
new file mode 100644
index 000000000000..59a562e0e58b
--- /dev/null
+++ b/packages/astro/e2e/fixtures/custom-client-directives/tsconfig.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    // preserveSymlinks set to true so the augmented `declare module 'astro'` works.
+    // This is only needed because we link Astro locally.
+    "preserveSymlinks": true
+  },
+  "include": ["./src/**/*"]
+}
\ No newline at end of file
diff --git a/packages/astro/e2e/test-utils.js b/packages/astro/e2e/test-utils.js
index 88daa8eec282..a11ba868b625 100644
--- a/packages/astro/e2e/test-utils.js
+++ b/packages/astro/e2e/test-utils.js
@@ -55,7 +55,6 @@ export async function getErrorOverlayContent(page) {
 }
 
 /**
- * @param {import('@playwright/test').Locator} el
  * @returns {Promise<string>}
  */
 export async function getColor(el) {
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 5fd28d282eb6..56d9ed1028e6 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -137,6 +137,7 @@
     "devalue": "^4.2.0",
     "diff": "^5.1.0",
     "es-module-lexer": "^1.1.0",
+    "esbuild": "^0.17.18",
     "estree-walker": "3.0.0",
     "execa": "^6.1.0",
     "fast-glob": "^3.2.11",
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 0f8cf424017f..e20e0e5a8320 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -55,6 +55,10 @@ export interface AstroBuiltinProps {
 	'client:only'?: boolean | string;
 }
 
+// Allow users to extend this for astro-jsx.d.ts
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface AstroClientDirectives {}
+
 export interface AstroBuiltinAttributes {
 	'class:list'?:
 		| Record<string, boolean>
@@ -1075,6 +1079,28 @@ export interface AstroUserConfig {
 		 */
 		inlineStylesheets?: 'always' | 'auto' | 'never';
 
+		/**
+		 * @docs
+		 * @name experimental.customClientDirectives
+		 * @type {boolean}
+		 * @default `false`
+		 * @version 2.5.0
+		 * @description
+		 * Allow integrations to use the [experimental `addClientDirective` API](/en/reference/integrations-reference/#addclientdirective-option) in the `astro:config:setup` hook
+		 * to add custom client directives in Astro files.
+		 *
+		 * To enable this feature, set `experimental.customClientDirectives` to `true` in your Astro config:
+		 *
+		 * ```js
+		 * {
+		 * 	experimental: {
+		 *		customClientDirectives: true,
+		 * 	},
+		 * }
+		 * ```
+		 */
+		customClientDirectives?: boolean;
+
 		/**
 		 * @docs
 		 * @name experimental.middleware
@@ -1206,6 +1232,10 @@ export interface AstroSettings {
 		stage: InjectedScriptStage;
 		content: string;
 	}[];
+	/**
+	 * Map of directive name (e.g. `load`) to the directive script code
+	 */
+	clientDirectives: Map<string, string>;
 	tsConfig: TsConfigJson | undefined;
 	tsConfigPath: string | undefined;
 	watchFiles: string[];
@@ -1654,6 +1684,7 @@ export interface AstroIntegration {
 			addWatchFile: (path: URL | string) => void;
 			injectScript: (stage: InjectedScriptStage, content: string) => void;
 			injectRoute: (injectRoute: InjectedRoute) => void;
+			addClientDirective: (directive: ClientDirectiveConfig) => void;
 			// TODO: Add support for `injectElement()` for full HTML element injection, not just scripts.
 			// This may require some refactoring of `scripts`, `styles`, and `links` into something
 			// more generalized. Consider the SSR use-case as well.
@@ -1750,6 +1781,7 @@ export interface SSRMetadata {
 	hasDirectives: Set<string>;
 	hasRenderedHead: boolean;
 	headInTree: boolean;
+	clientDirectives: Map<string, string>;
 }
 
 /**
@@ -1815,3 +1847,29 @@ export type CreatePreviewServer = (
 export interface PreviewModule {
 	default: CreatePreviewServer;
 }
+
+/* Client Directives */
+type DirectiveHydrate = () => Promise<void>;
+type DirectiveLoad = () => Promise<DirectiveHydrate>;
+
+type DirectiveOptions = {
+	/**
+	 * The component displayName
+	 */
+	name: string;
+	/**
+	 * The attribute value provided
+	 */
+	value: string;
+};
+
+export type ClientDirective = (
+	load: DirectiveLoad,
+	options: DirectiveOptions,
+	el: HTMLElement
+) => void;
+
+export interface ClientDirectiveConfig {
+	name: string;
+	entrypoint: string;
+}
diff --git a/packages/astro/src/core/app/common.ts b/packages/astro/src/core/app/common.ts
index 6fd13d9b9086..58898b2fe51f 100644
--- a/packages/astro/src/core/app/common.ts
+++ b/packages/astro/src/core/app/common.ts
@@ -15,11 +15,13 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest):
 
 	const assets = new Set<string>(serializedManifest.assets);
 	const componentMetadata = new Map(serializedManifest.componentMetadata);
+	const clientDirectives = new Map(serializedManifest.clientDirectives);
 
 	return {
 		...serializedManifest,
 		assets,
 		componentMetadata,
+		clientDirectives,
 		routes,
 	};
 }
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
index d7d4241d20d4..8f37bf560828 100644
--- a/packages/astro/src/core/app/index.ts
+++ b/packages/astro/src/core/app/index.ts
@@ -65,6 +65,7 @@ export class App {
 			markdown: manifest.markdown,
 			mode: 'production',
 			renderers: manifest.renderers,
+			clientDirectives: manifest.clientDirectives,
 			async resolve(specifier: string) {
 				if (!(specifier in manifest.entryModules)) {
 					throw new Error(`Unable to resolve [${specifier}]`);
diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts
index ab6a50b9c946..89c5bad37a4d 100644
--- a/packages/astro/src/core/app/types.ts
+++ b/packages/astro/src/core/app/types.ts
@@ -41,16 +41,24 @@ export interface SSRManifest {
 	markdown: MarkdownRenderingOptions;
 	pageMap: Map<ComponentPath, ComponentInstance>;
 	renderers: SSRLoadedRenderer[];
+	/**
+	 * Map of directive name (e.g. `load`) to the directive script code
+	 */
+	clientDirectives: Map<string, string>;
 	entryModules: Record<string, string>;
 	assets: Set<string>;
 	componentMetadata: SSRResult['componentMetadata'];
 	middleware?: AstroMiddlewareInstance<unknown>;
 }
 
-export type SerializedSSRManifest = Omit<SSRManifest, 'routes' | 'assets' | 'componentMetadata'> & {
+export type SerializedSSRManifest = Omit<
+	SSRManifest,
+	'routes' | 'assets' | 'componentMetadata' | 'clientDirectives'
+> & {
 	routes: SerializedRouteInfo[];
 	assets: string[];
 	componentMetadata: [string, SSRComponentMetadata][];
+	clientDirectives: [string, string][];
 };
 
 export type AdapterCreateExports<T = any> = (
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
index 8a85232c4253..e330d472ada9 100644
--- a/packages/astro/src/core/build/generate.ts
+++ b/packages/astro/src/core/build/generate.ts
@@ -421,6 +421,7 @@ async function generatePath(
 		markdown: settings.config.markdown,
 		mode: opts.mode,
 		renderers,
+		clientDirectives: settings.clientDirectives,
 		async resolve(specifier: string) {
 			const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier);
 			if (typeof hashedFilePath !== 'string') {
diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts
index 8259e5e15342..935e7b38059d 100644
--- a/packages/astro/src/core/build/plugins/plugin-ssr.ts
+++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts
@@ -237,6 +237,7 @@ function buildManifest(
 		pageMap: null as any,
 		componentMetadata: Array.from(internals.componentMetadata),
 		renderers: [],
+		clientDirectives: Array.from(settings.clientDirectives),
 		entryModules,
 		assets: staticFiles.map(prefixAssetPath),
 	};
diff --git a/packages/astro/src/core/client-directive/build.ts b/packages/astro/src/core/client-directive/build.ts
new file mode 100644
index 000000000000..591c0c4372d2
--- /dev/null
+++ b/packages/astro/src/core/client-directive/build.ts
@@ -0,0 +1,33 @@
+import { build } from 'esbuild';
+
+/**
+ * Build a client directive entrypoint into code that can directly run in a `<script>` tag.
+ */
+export async function buildClientDirectiveEntrypoint(name: string, entrypoint: string) {
+	const stringifiedName = JSON.stringify(name);
+	const stringifiedEntrypoint = JSON.stringify(entrypoint);
+
+	// NOTE: when updating this stdin code, make sure to also update `packages/astro/scripts/prebuild.ts`
+	// that prebuilds the client directive with a similar code too.
+	const output = await build({
+		stdin: {
+			contents: `\
+import directive from ${stringifiedEntrypoint};
+
+(self.Astro || (self.Astro = {}))[${stringifiedName}] = directive;
+
+window.dispatchEvent(new Event('astro:' + ${stringifiedName}));`,
+			resolveDir: process.cwd(),
+		},
+		absWorkingDir: process.cwd(),
+		format: 'iife',
+		minify: true,
+		bundle: true,
+		write: false,
+	});
+
+	const outputFile = output.outputFiles?.[0];
+	if (!outputFile) return '';
+
+	return outputFile.text;
+}
diff --git a/packages/astro/src/core/client-directive/default.ts b/packages/astro/src/core/client-directive/default.ts
new file mode 100644
index 000000000000..352763ba67c7
--- /dev/null
+++ b/packages/astro/src/core/client-directive/default.ts
@@ -0,0 +1,15 @@
+import idlePrebuilt from '../../runtime/client/idle.prebuilt.js';
+import loadPrebuilt from '../../runtime/client/load.prebuilt.js';
+import mediaPrebuilt from '../../runtime/client/media.prebuilt.js';
+import onlyPrebuilt from '../../runtime/client/only.prebuilt.js';
+import visiblePrebuilt from '../../runtime/client/visible.prebuilt.js';
+
+export function getDefaultClientDirectives() {
+	return new Map([
+		['idle', idlePrebuilt],
+		['load', loadPrebuilt],
+		['media', mediaPrebuilt],
+		['only', onlyPrebuilt],
+		['visible', visiblePrebuilt],
+	]);
+}
diff --git a/packages/astro/src/core/client-directive/index.ts b/packages/astro/src/core/client-directive/index.ts
new file mode 100644
index 000000000000..7c1a9a71cbaa
--- /dev/null
+++ b/packages/astro/src/core/client-directive/index.ts
@@ -0,0 +1,2 @@
+export { buildClientDirectiveEntrypoint } from './build.js';
+export { getDefaultClientDirectives } from './default.js';
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index fd8d88c4df58..54640b19fdf8 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -38,6 +38,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
 	legacy: {},
 	experimental: {
 		assets: false,
+		customClientDirecives: false,
 		inlineStylesheets: 'never',
 		middleware: false,
 	},
@@ -195,6 +196,10 @@ export const AstroConfigSchema = z.object({
 	experimental: z
 		.object({
 			assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets),
+			customClientDirectives: z
+				.boolean()
+				.optional()
+				.default(ASTRO_CONFIG_DEFAULTS.experimental.customClientDirecives),
 			inlineStylesheets: z
 				.enum(['always', 'auto', 'never'])
 				.optional()
diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts
index 4d8278b80154..fa90af4c0407 100644
--- a/packages/astro/src/core/config/settings.ts
+++ b/packages/astro/src/core/config/settings.ts
@@ -7,6 +7,7 @@ import { markdownContentEntryType } from '../../vite-plugin-markdown/content-ent
 import { createDefaultDevConfig } from './config.js';
 import { AstroTimer } from './timer.js';
 import { loadTSConfig } from './tsconfig.js';
+import { getDefaultClientDirectives } from '../client-directive/index.js';
 
 export function createBaseSettings(config: AstroConfig): AstroSettings {
 	return {
@@ -23,6 +24,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
 		contentEntryTypes: [markdownContentEntryType],
 		renderers: [jsxRenderer],
 		scripts: [],
+		clientDirectives: getDefaultClientDirectives(),
 		watchFiles: [],
 		forceDisableTelemetry: false,
 		timer: new AstroTimer(),
diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts
index fd57ad8bc673..1c12a1a8d6e5 100644
--- a/packages/astro/src/core/render/core.ts
+++ b/packages/astro/src/core/render/core.ts
@@ -140,6 +140,7 @@ export async function renderPage({ mod, renderContext, env, apiContext }: Render
 		componentMetadata: renderContext.componentMetadata,
 		resolve: env.resolve,
 		renderers: env.renderers,
+		clientDirectives: env.clientDirectives,
 		request: renderContext.request,
 		site: env.site,
 		scripts: renderContext.scripts,
diff --git a/packages/astro/src/core/render/dev/environment.ts b/packages/astro/src/core/render/dev/environment.ts
index 5aa3688ddc9a..6a45f9c36e4d 100644
--- a/packages/astro/src/core/render/dev/environment.ts
+++ b/packages/astro/src/core/render/dev/environment.ts
@@ -25,6 +25,7 @@ export function createDevelopmentEnvironment(
 		mode,
 		// This will be overridden in the dev server
 		renderers: [],
+		clientDirectives: settings.clientDirectives,
 		resolve: createResolve(loader, settings.config.root),
 		routeCache: new RouteCache(logging, mode),
 		site: settings.config.site,
diff --git a/packages/astro/src/core/render/environment.ts b/packages/astro/src/core/render/environment.ts
index 4c5f6bacef37..d4a1cc38ef6e 100644
--- a/packages/astro/src/core/render/environment.ts
+++ b/packages/astro/src/core/render/environment.ts
@@ -2,6 +2,7 @@ import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
 import type { RuntimeMode, SSRLoadedRenderer } from '../../@types/astro';
 import type { LogOptions } from '../logger/core.js';
 import { RouteCache } from './route-cache.js';
+import { getDefaultClientDirectives } from '../client-directive/default.js';
 
 /**
  * An environment represents the static parts of rendering that do not change
@@ -16,6 +17,7 @@ export interface Environment {
 	/** "development" or "production" */
 	mode: RuntimeMode;
 	renderers: SSRLoadedRenderer[];
+	clientDirectives: Map<string, string>;
 	resolve: (s: string) => Promise<string>;
 	routeCache: RouteCache;
 	site?: string;
@@ -46,6 +48,7 @@ export function createBasicEnvironment(options: CreateBasicEnvironmentArgs): Env
 		},
 		mode,
 		renderers: options.renderers ?? [],
+		clientDirectives: getDefaultClientDirectives(),
 		resolve: options.resolve ?? ((s: string) => Promise.resolve(s)),
 		routeCache: new RouteCache(options.logging, mode),
 		ssr: options.ssr ?? true,
diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts
index 598ec116f785..e18ed7eb9ed6 100644
--- a/packages/astro/src/core/render/result.ts
+++ b/packages/astro/src/core/render/result.ts
@@ -42,6 +42,7 @@ export interface CreateResultArgs {
 	pathname: string;
 	props: Props;
 	renderers: SSRLoadedRenderer[];
+	clientDirectives: Map<string, string>;
 	resolve: (s: string) => Promise<string>;
 	site: string | undefined;
 	links?: Set<SSRElement>;
@@ -132,7 +133,8 @@ class Slots {
 let renderMarkdown: any = null;
 
 export function createResult(args: CreateResultArgs): SSRResult {
-	const { markdown, params, pathname, renderers, request, resolve, locals } = args;
+	const { markdown, params, pathname, renderers, clientDirectives, request, resolve, locals } =
+		args;
 
 	const url = new URL(request.url);
 	const headers = new Headers();
@@ -260,6 +262,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
 			hasRenderedHead: false,
 			hasDirectives: new Set(),
 			headInTree: false,
+			clientDirectives,
 		},
 		response,
 	};
diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts
index d306e7be3463..f833d94a1dc8 100644
--- a/packages/astro/src/integrations/index.ts
+++ b/packages/astro/src/integrations/index.ts
@@ -17,6 +17,7 @@ import type { PageBuildData } from '../core/build/types';
 import { mergeConfig } from '../core/config/config.js';
 import { info, type LogOptions } from '../core/logger/core.js';
 import { mdxContentEntryType } from '../vite-plugin-markdown/content-entry-type.js';
+import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js';
 
 async function withTakingALongTimeMsg<T>({
 	name,
@@ -55,6 +56,7 @@ export async function runHookConfigSetup({
 
 	let updatedConfig: AstroConfig = { ...settings.config };
 	let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
+	let addedClientDirectives = new Map<string, Promise<string>>();
 
 	for (const integration of settings.config.integrations) {
 		/**
@@ -97,6 +99,19 @@ export async function runHookConfigSetup({
 				addWatchFile: (path) => {
 					updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
 				},
+				addClientDirective: ({ name, entrypoint }) => {
+					if (!settings.config.experimental.customClientDirectives) {
+						throw new Error(
+							`The "${integration.name}" integration is trying to add the "${name}" client directive, but the \`experimental.customClientDirectives\` config is not enabled.`
+						);
+					}
+					if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) {
+						throw new Error(
+							`The "${integration.name}" integration is trying to add the "${name}" client directive, but it already exists.`
+						);
+					}
+					addedClientDirectives.set(name, buildClientDirectiveEntrypoint(name, entrypoint));
+				},
 			};
 
 			// ---
@@ -138,6 +153,11 @@ export async function runHookConfigSetup({
 			) {
 				addContentEntryType(mdxContentEntryType);
 			}
+
+			// Add custom client directives to settings, waiting for compiled code by esbuild
+			for (const [name, compiled] of addedClientDirectives) {
+				updatedSettings.clientDirectives.set(name, await compiled);
+			}
 		}
 	}
 
diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts
index 861914336a14..9caf42aaf33a 100644
--- a/packages/astro/src/jsx/babel.ts
+++ b/packages/astro/src/jsx/babel.ts
@@ -3,7 +3,6 @@ import * as t from '@babel/types';
 import { AstroErrorData } from '../core/errors/errors-data.js';
 import { AstroError } from '../core/errors/errors.js';
 import { resolvePath } from '../core/util.js';
-import { HydrationDirectiveProps } from '../runtime/server/hydration.js';
 import type { PluginMetadata } from '../vite-plugin-astro/types';
 
 const ClientOnlyPlaceholder = 'astro-client-only';
@@ -285,7 +284,7 @@ export default function astroJSX(): PluginObj {
 						for (const attr of parentNode.openingElement.attributes) {
 							if (t.isJSXAttribute(attr)) {
 								const name = jsxAttributeToString(attr);
-								if (HydrationDirectiveProps.has(name)) {
+								if (name.startsWith('client:')) {
 									// eslint-disable-next-line
 									console.warn(
 										`You are attempting to render <${displayName} ${name} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`
diff --git a/packages/astro/src/runtime/client/idle.ts b/packages/astro/src/runtime/client/idle.ts
index 4af28bd46180..48aa9dc1f0c9 100644
--- a/packages/astro/src/runtime/client/idle.ts
+++ b/packages/astro/src/runtime/client/idle.ts
@@ -1,13 +1,15 @@
-(self.Astro = self.Astro || {}).idle = (getHydrateCallback) => {
+import type { ClientDirective } from '../../@types/astro';
+
+const idleDirective: ClientDirective = (load) => {
 	const cb = async () => {
-		let hydrate = await getHydrateCallback();
+		const hydrate = await load();
 		await hydrate();
 	};
-
 	if ('requestIdleCallback' in window) {
 		(window as any).requestIdleCallback(cb);
 	} else {
 		setTimeout(cb, 200);
 	}
 };
-window.dispatchEvent(new Event('astro:idle'));
+
+export default idleDirective;
diff --git a/packages/astro/src/runtime/client/load.ts b/packages/astro/src/runtime/client/load.ts
index 426c6c68aae9..15a2f1dcb8c4 100644
--- a/packages/astro/src/runtime/client/load.ts
+++ b/packages/astro/src/runtime/client/load.ts
@@ -1,7 +1,8 @@
-(self.Astro = self.Astro || {}).load = (getHydrateCallback) => {
-	(async () => {
-		let hydrate = await getHydrateCallback();
-		await hydrate();
-	})();
+import type { ClientDirective } from '../../@types/astro';
+
+const loadDirective: ClientDirective = async (load) => {
+	const hydrate = await load();
+	await hydrate();
 };
-window.dispatchEvent(new Event('astro:load'));
+
+export default loadDirective;
diff --git a/packages/astro/src/runtime/client/media.ts b/packages/astro/src/runtime/client/media.ts
index c180d396a1e8..3d92d37134d0 100644
--- a/packages/astro/src/runtime/client/media.ts
+++ b/packages/astro/src/runtime/client/media.ts
@@ -1,9 +1,11 @@
+import type { ClientDirective } from '../../@types/astro';
+
 /**
  * Hydrate this component when a matching media query is found
  */
-(self.Astro = self.Astro || {}).media = (getHydrateCallback, options) => {
+const mediaDirective: ClientDirective = (load, options) => {
 	const cb = async () => {
-		let hydrate = await getHydrateCallback();
+		const hydrate = await load();
 		await hydrate();
 	};
 
@@ -16,4 +18,5 @@
 		}
 	}
 };
-window.dispatchEvent(new Event('astro:media'));
+
+export default mediaDirective;
diff --git a/packages/astro/src/runtime/client/only.ts b/packages/astro/src/runtime/client/only.ts
index e8272edbb429..f67ae3ace33a 100644
--- a/packages/astro/src/runtime/client/only.ts
+++ b/packages/astro/src/runtime/client/only.ts
@@ -1,10 +1,11 @@
+import type { ClientDirective } from '../../@types/astro';
+
 /**
  * Hydrate this component only on the client
  */
-(self.Astro = self.Astro || {}).only = (getHydrateCallback) => {
-	(async () => {
-		let hydrate = await getHydrateCallback();
-		await hydrate();
-	})();
+const onlyDirective: ClientDirective = async (load) => {
+	const hydrate = await load();
+	await hydrate();
 };
-window.dispatchEvent(new Event('astro:only'));
+
+export default onlyDirective;
diff --git a/packages/astro/src/runtime/client/visible.ts b/packages/astro/src/runtime/client/visible.ts
index 28975040cf0a..e42b0433996d 100644
--- a/packages/astro/src/runtime/client/visible.ts
+++ b/packages/astro/src/runtime/client/visible.ts
@@ -1,15 +1,17 @@
+import type { ClientDirective } from '../../@types/astro';
+
 /**
  * Hydrate this component when one of it's children becomes visible
  * We target the children because `astro-island` is set to `display: contents`
  * which doesn't work with IntersectionObserver
  */
-(self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => {
+const visibleDirective: ClientDirective = (load, _options, el) => {
 	const cb = async () => {
-		let hydrate = await getHydrateCallback();
+		const hydrate = await load();
 		await hydrate();
 	};
 
-	let io = new IntersectionObserver((entries) => {
+	const io = new IntersectionObserver((entries) => {
 		for (const entry of entries) {
 			if (!entry.isIntersecting) continue;
 			// As soon as we hydrate, disconnect this IntersectionObserver for every `astro-island`
@@ -19,9 +21,10 @@
 		}
 	});
 
-	for (let i = 0; i < root.children.length; i++) {
-		const child = root.children[i];
+	for (let i = 0; i < el.children.length; i++) {
+		const child = el.children[i];
 		io.observe(child);
 	}
 };
-window.dispatchEvent(new Event('astro:visible'));
+
+export default visibleDirective;
diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts
index 4729708e7606..9394be581146 100644
--- a/packages/astro/src/runtime/server/hydration.ts
+++ b/packages/astro/src/runtime/server/hydration.ts
@@ -9,10 +9,6 @@ import { escapeHTML } from './escape.js';
 import { serializeProps } from './serialize.js';
 import { serializeListValue } from './util.js';
 
-const HydrationDirectivesRaw = ['load', 'idle', 'media', 'visible', 'only'];
-const HydrationDirectives = new Set(HydrationDirectivesRaw);
-export const HydrationDirectiveProps = new Set(HydrationDirectivesRaw.map((n) => `client:${n}`));
-
 export interface HydrationMetadata {
 	directive: string;
 	value: string;
@@ -29,8 +25,8 @@ interface ExtractedProps {
 // Used to extract the directives, aka `client:load` information about a component.
 // Finds these special props and removes them from what gets passed into the component.
 export function extractDirectives(
-	displayName: string,
-	inputProps: Record<string | number | symbol, any>
+	inputProps: Record<string | number | symbol, any>,
+	clientDirectives: SSRResult['_metadata']['clientDirectives']
 ): ExtractedProps {
 	let extracted: ExtractedProps = {
 		isPage: false,
@@ -74,11 +70,12 @@ export function extractDirectives(
 					extracted.hydration.value = value;
 
 					// throw an error if an invalid hydration directive was provided
-					if (!HydrationDirectives.has(extracted.hydration.directive)) {
+					if (!clientDirectives.has(extracted.hydration.directive)) {
+						const hydrationMethods = Array.from(clientDirectives.keys())
+							.map((d) => `client:${d}`)
+							.join(', ');
 						throw new Error(
-							`Error: invalid hydration directive "${key}". Supported hydration methods: ${Array.from(
-								HydrationDirectiveProps
-							).join(', ')}`
+							`Error: invalid hydration directive "${key}". Supported hydration methods: ${hydrationMethods}`
 						);
 					}
 
diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts
index 47ce7f49569a..ed5044575ae5 100644
--- a/packages/astro/src/runtime/server/render/astro/instance.ts
+++ b/packages/astro/src/runtime/server/render/astro/instance.ts
@@ -2,7 +2,6 @@ import type { SSRResult } from '../../../../@types/astro';
 import type { ComponentSlots } from '../slot.js';
 import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js';
 
-import { HydrationDirectiveProps } from '../../hydration.js';
 import { isPromise } from '../../util.js';
 import { renderChild } from '../any.js';
 import { isAPropagatingComponent } from './factory.js';
@@ -62,7 +61,7 @@ export class AstroComponentInstance {
 function validateComponentProps(props: any, displayName: string) {
 	if (props != null) {
 		for (const prop of Object.keys(props)) {
-			if (HydrationDirectiveProps.has(prop)) {
+			if (prop.startsWith('client:')) {
 				// eslint-disable-next-line
 				console.warn(
 					`You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`
diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts
index e9e74f9fa03e..e9be3bf8ba04 100644
--- a/packages/astro/src/runtime/server/render/common.ts
+++ b/packages/astro/src/runtime/server/render/common.ts
@@ -39,7 +39,7 @@ export function stringifyChunk(
 					? 'directive'
 					: null;
 				if (prescriptType) {
-					let prescripts = getPrescripts(prescriptType, hydration.directive);
+					let prescripts = getPrescripts(result, prescriptType, hydration.directive);
 					return markHTMLString(prescripts);
 				} else {
 					return '';
diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts
index cc8851522644..afedd8858b1c 100644
--- a/packages/astro/src/runtime/server/render/component.ts
+++ b/packages/astro/src/runtime/server/render/component.ts
@@ -67,10 +67,10 @@ async function renderFrameworkComponent(
 		);
 	}
 
-	const { renderers } = result._metadata;
+	const { renderers, clientDirectives } = result._metadata;
 	const metadata: AstroComponentMetadata = { displayName };
 
-	const { hydration, isPage, props } = extractDirectives(displayName, _props);
+	const { hydration, isPage, props } = extractDirectives(_props, clientDirectives);
 	let html = '';
 	let attrs: Record<string, string> | undefined = undefined;
 
diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts
index 1d57c07e92f8..b466d1df3871 100644
--- a/packages/astro/src/runtime/server/scripts.ts
+++ b/packages/astro/src/runtime/server/scripts.ts
@@ -1,12 +1,8 @@
 import type { SSRResult } from '../../@types/astro';
-
-import idlePrebuilt from '../client/idle.prebuilt.js';
-import loadPrebuilt from '../client/load.prebuilt.js';
-import mediaPrebuilt from '../client/media.prebuilt.js';
-import onlyPrebuilt from '../client/only.prebuilt.js';
-import visiblePrebuilt from '../client/visible.prebuilt.js';
 import islandScript from './astro-island.prebuilt.js';
 
+const ISLAND_STYLES = `<style>astro-island,astro-slot{display:contents}</style>`;
+
 export function determineIfNeedsHydrationScript(result: SSRResult): boolean {
 	if (result._metadata.hasHydrationScript) {
 		return false;
@@ -14,14 +10,6 @@ export function determineIfNeedsHydrationScript(result: SSRResult): boolean {
 	return (result._metadata.hasHydrationScript = true);
 }
 
-export const hydrationScripts: Record<string, string> = {
-	idle: idlePrebuilt,
-	load: loadPrebuilt,
-	only: onlyPrebuilt,
-	media: mediaPrebuilt,
-	visible: visiblePrebuilt,
-};
-
 export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: string): boolean {
 	if (result._metadata.hasDirectives.has(directive)) {
 		return false;
@@ -32,26 +20,28 @@ export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: s
 
 export type PrescriptType = null | 'both' | 'directive';
 
-function getDirectiveScriptText(directive: string): string {
-	if (!(directive in hydrationScripts)) {
+function getDirectiveScriptText(result: SSRResult, directive: string): string {
+	const clientDirectives = result._metadata.clientDirectives;
+	const clientDirective = clientDirectives.get(directive);
+	if (!clientDirective) {
 		throw new Error(`Unknown directive: ${directive}`);
 	}
-	const directiveScriptText = hydrationScripts[directive];
-	return directiveScriptText;
+	return clientDirective;
 }
 
-export function getPrescripts(type: PrescriptType, directive: string): string {
+export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string {
 	// Note that this is a classic script, not a module script.
 	// This is so that it executes immediate, and when the browser encounters
 	// an astro-island element the callbacks will fire immediately, causing the JS
 	// deps to be loaded immediately.
 	switch (type) {
 		case 'both':
-			return `<style>astro-island,astro-slot{display:contents}</style><script>${
-				getDirectiveScriptText(directive) + islandScript
-			}</script>`;
+			return `${ISLAND_STYLES}<script>${getDirectiveScriptText(
+				result,
+				directive
+			)};${islandScript}</script>`;
 		case 'directive':
-			return `<script>${getDirectiveScriptText(directive)}</script>`;
+			return `<script>${getDirectiveScriptText(result, directive)}</script>`;
 	}
 	return '';
 }
diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js
index f933a13ada33..e26ab8c6d508 100644
--- a/packages/astro/test/test-utils.js
+++ b/packages/astro/test/test-utils.js
@@ -127,12 +127,21 @@ export async function loadFixture(inlineConfig) {
 	if (inlineConfig.base && !inlineConfig.base.endsWith('/')) {
 		config.base = inlineConfig.base + '/';
 	}
-	let settings = createSettings(config, fileURLToPath(cwd));
-	if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) {
-		// Enable default JSX integration. It needs to come first, so unshift rather than push!
-		const { default: jsxRenderer } = await import('astro/jsx/renderer.js');
-		settings.renderers.unshift(jsxRenderer);
-	}
+
+	/**
+	 * The dev/build/sync/check commands run integrations' `astro:config:setup` hook that could mutate
+	 * the `AstroSettings`. This function helps to create a fresh settings object that is used by the
+	 * command functions below to prevent tests from polluting each other.
+	 */
+	const getSettings = async () => {
+		let settings = createSettings(config, fileURLToPath(cwd));
+		if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) {
+			// Enable default JSX integration. It needs to come first, so unshift rather than push!
+			const { default: jsxRenderer } = await import('astro/jsx/renderer.js');
+			settings.renderers.unshift(jsxRenderer);
+		}
+		return settings;
+	};
 
 	/** @type {import('@astrojs/telemetry').AstroTelemetry} */
 	const telemetry = {
@@ -170,17 +179,17 @@ export async function loadFixture(inlineConfig) {
 	let devServer;
 
 	return {
-		build: (opts = {}) => {
+		build: async (opts = {}) => {
 			process.env.NODE_ENV = 'production';
-			return build(settings, { logging, telemetry, ...opts });
+			return build(await getSettings(), { logging, telemetry, ...opts });
 		},
-		sync: (opts) => sync(settings, { logging, fs, ...opts }),
+		sync: async (opts) => sync(await getSettings(), { logging, fs, ...opts }),
 		check: async (opts) => {
-			return await check(settings, { logging, ...opts });
+			return await check(await getSettings(), { logging, ...opts });
 		},
 		startDevServer: async (opts = {}) => {
 			process.env.NODE_ENV = 'development';
-			devServer = await dev(settings, { logging, telemetry, ...opts });
+			devServer = await dev(await getSettings(), { logging, telemetry, ...opts });
 			config.server.host = parseAddressToHost(devServer.address.address); // update host
 			config.server.port = devServer.address.port; // update port
 			return devServer;
@@ -202,7 +211,7 @@ export async function loadFixture(inlineConfig) {
 		},
 		preview: async (opts = {}) => {
 			process.env.NODE_ENV = 'production';
-			const previewServer = await preview(settings, {
+			const previewServer = await preview(await getSettings(), {
 				logging,
 				telemetry,
 				...opts,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 35fe04342dd1..f1af7cc696b2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -603,6 +603,9 @@ importers:
       es-module-lexer:
         specifier: ^1.1.0
         version: 1.1.1
+      esbuild:
+        specifier: ^0.17.18
+        version: 0.17.18
       estree-walker:
         specifier: 3.0.0
         version: 3.0.0
@@ -941,6 +944,21 @@ importers:
         specifier: workspace:*
         version: link:../../..
 
+  packages/astro/e2e/fixtures/custom-client-directives:
+    dependencies:
+      '@astrojs/react':
+        specifier: workspace:*
+        version: link:../../../../integrations/react
+      astro:
+        specifier: workspace:*
+        version: link:../../..
+      react:
+        specifier: ^18.0.0
+        version: 18.2.0
+      react-dom:
+        specifier: ^18.0.0
+        version: 18.2.0(react@18.2.0)
+
   packages/astro/e2e/fixtures/error-cyclic:
     dependencies:
       '@astrojs/preact':
@@ -7400,6 +7418,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/android-arm64@0.17.18:
+    resolution: {integrity: sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [android]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/android-arm@0.15.18:
     resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==}
     engines: {node: '>=12'}
@@ -7417,6 +7443,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/android-arm@0.17.18:
+    resolution: {integrity: sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [android]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/android-x64@0.17.12:
     resolution: {integrity: sha512-m4OsaCr5gT+se25rFPHKQXARMyAehHTQAz4XX1Vk3d27VtqiX0ALMBPoXZsGaB6JYryCLfgGwUslMqTfqeLU0w==}
     engines: {node: '>=12'}
@@ -7425,6 +7459,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/android-x64@0.17.18:
+    resolution: {integrity: sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [android]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/darwin-arm64@0.17.12:
     resolution: {integrity: sha512-O3GCZghRIx+RAN0NDPhyyhRgwa19MoKlzGonIb5hgTj78krqp9XZbYCvFr9N1eUxg0ZQEpiiZ4QvsOQwBpP+lg==}
     engines: {node: '>=12'}
@@ -7433,6 +7475,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/darwin-arm64@0.17.18:
+    resolution: {integrity: sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [darwin]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/darwin-x64@0.17.12:
     resolution: {integrity: sha512-5D48jM3tW27h1qjaD9UNRuN+4v0zvksqZSPZqeSWggfMlsVdAhH3pwSfQIFJwcs9QJ9BRibPS4ViZgs3d2wsCA==}
     engines: {node: '>=12'}
@@ -7441,6 +7491,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/darwin-x64@0.17.18:
+    resolution: {integrity: sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [darwin]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/freebsd-arm64@0.17.12:
     resolution: {integrity: sha512-OWvHzmLNTdF1erSvrfoEBGlN94IE6vCEaGEkEH29uo/VoONqPnoDFfShi41Ew+yKimx4vrmmAJEGNoyyP+OgOQ==}
     engines: {node: '>=12'}
@@ -7449,6 +7507,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/freebsd-arm64@0.17.18:
+    resolution: {integrity: sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [freebsd]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/freebsd-x64@0.17.12:
     resolution: {integrity: sha512-A0Xg5CZv8MU9xh4a+7NUpi5VHBKh1RaGJKqjxe4KG87X+mTjDE6ZvlJqpWoeJxgfXHT7IMP9tDFu7IZ03OtJAw==}
     engines: {node: '>=12'}
@@ -7457,6 +7523,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/freebsd-x64@0.17.18:
+    resolution: {integrity: sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [freebsd]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-arm64@0.17.12:
     resolution: {integrity: sha512-cK3AjkEc+8v8YG02hYLQIQlOznW+v9N+OI9BAFuyqkfQFR+DnDLhEM5N8QRxAUz99cJTo1rLNXqRrvY15gbQUg==}
     engines: {node: '>=12'}
@@ -7465,6 +7539,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-arm64@0.17.18:
+    resolution: {integrity: sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-arm@0.17.12:
     resolution: {integrity: sha512-WsHyJ7b7vzHdJ1fv67Yf++2dz3D726oO3QCu8iNYik4fb5YuuReOI9OtA+n7Mk0xyQivNTPbl181s+5oZ38gyA==}
     engines: {node: '>=12'}
@@ -7473,6 +7555,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-arm@0.17.18:
+    resolution: {integrity: sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-ia32@0.17.12:
     resolution: {integrity: sha512-jdOBXJqcgHlah/nYHnj3Hrnl9l63RjtQ4vn9+bohjQPI2QafASB5MtHAoEv0JQHVb/xYQTFOeuHnNYE1zF7tYw==}
     engines: {node: '>=12'}
@@ -7481,6 +7571,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-ia32@0.17.18:
+    resolution: {integrity: sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-loong64@0.15.18:
     resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==}
     engines: {node: '>=12'}
@@ -7498,6 +7596,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-loong64@0.17.18:
+    resolution: {integrity: sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==}
+    engines: {node: '>=12'}
+    cpu: [loong64]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-mips64el@0.17.12:
     resolution: {integrity: sha512-o8CIhfBwKcxmEENOH9RwmUejs5jFiNoDw7YgS0EJTF6kgPgcqLFjgoc5kDey5cMHRVCIWc6kK2ShUePOcc7RbA==}
     engines: {node: '>=12'}
@@ -7506,6 +7612,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-mips64el@0.17.18:
+    resolution: {integrity: sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==}
+    engines: {node: '>=12'}
+    cpu: [mips64el]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-ppc64@0.17.12:
     resolution: {integrity: sha512-biMLH6NR/GR4z+ap0oJYb877LdBpGac8KfZoEnDiBKd7MD/xt8eaw1SFfYRUeMVx519kVkAOL2GExdFmYnZx3A==}
     engines: {node: '>=12'}
@@ -7514,6 +7628,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-ppc64@0.17.18:
+    resolution: {integrity: sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==}
+    engines: {node: '>=12'}
+    cpu: [ppc64]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-riscv64@0.17.12:
     resolution: {integrity: sha512-jkphYUiO38wZGeWlfIBMB72auOllNA2sLfiZPGDtOBb1ELN8lmqBrlMiucgL8awBw1zBXN69PmZM6g4yTX84TA==}
     engines: {node: '>=12'}
@@ -7522,6 +7644,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-riscv64@0.17.18:
+    resolution: {integrity: sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==}
+    engines: {node: '>=12'}
+    cpu: [riscv64]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-s390x@0.17.12:
     resolution: {integrity: sha512-j3ucLdeY9HBcvODhCY4b+Ds3hWGO8t+SAidtmWu/ukfLLG/oYDMaA+dnugTVAg5fnUOGNbIYL9TOjhWgQB8W5g==}
     engines: {node: '>=12'}
@@ -7530,6 +7660,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-s390x@0.17.18:
+    resolution: {integrity: sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==}
+    engines: {node: '>=12'}
+    cpu: [s390x]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/linux-x64@0.17.12:
     resolution: {integrity: sha512-uo5JL3cgaEGotaqSaJdRfFNSCUJOIliKLnDGWaVCgIKkHxwhYMm95pfMbWZ9l7GeW9kDg0tSxcy9NYdEtjwwmA==}
     engines: {node: '>=12'}
@@ -7538,6 +7676,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/linux-x64@0.17.18:
+    resolution: {integrity: sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [linux]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/netbsd-x64@0.17.12:
     resolution: {integrity: sha512-DNdoRg8JX+gGsbqt2gPgkgb00mqOgOO27KnrWZtdABl6yWTST30aibGJ6geBq3WM2TIeW6COs5AScnC7GwtGPg==}
     engines: {node: '>=12'}
@@ -7546,6 +7692,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/netbsd-x64@0.17.18:
+    resolution: {integrity: sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [netbsd]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/openbsd-x64@0.17.12:
     resolution: {integrity: sha512-aVsENlr7B64w8I1lhHShND5o8cW6sB9n9MUtLumFlPhG3elhNWtE7M1TFpj3m7lT3sKQUMkGFjTQBrvDDO1YWA==}
     engines: {node: '>=12'}
@@ -7554,6 +7708,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/openbsd-x64@0.17.18:
+    resolution: {integrity: sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [openbsd]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/sunos-x64@0.17.12:
     resolution: {integrity: sha512-qbHGVQdKSwi0JQJuZznS4SyY27tYXYF0mrgthbxXrZI3AHKuRvU+Eqbg/F0rmLDpW/jkIZBlCO1XfHUBMNJ1pg==}
     engines: {node: '>=12'}
@@ -7562,6 +7724,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/sunos-x64@0.17.18:
+    resolution: {integrity: sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [sunos]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/win32-arm64@0.17.12:
     resolution: {integrity: sha512-zsCp8Ql+96xXTVTmm6ffvoTSZSV2B/LzzkUXAY33F/76EajNw1m+jZ9zPfNJlJ3Rh4EzOszNDHsmG/fZOhtqDg==}
     engines: {node: '>=12'}
@@ -7570,6 +7740,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/win32-arm64@0.17.18:
+    resolution: {integrity: sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [win32]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/win32-ia32@0.17.12:
     resolution: {integrity: sha512-FfrFjR4id7wcFYOdqbDfDET3tjxCozUgbqdkOABsSFzoZGFC92UK7mg4JKRc/B3NNEf1s2WHxJ7VfTdVDPN3ng==}
     engines: {node: '>=12'}
@@ -7578,6 +7756,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/win32-ia32@0.17.18:
+    resolution: {integrity: sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [win32]
+    requiresBuild: true
+    optional: true
+
   /@esbuild/win32-x64@0.17.12:
     resolution: {integrity: sha512-JOOxw49BVZx2/5tW3FqkdjSD/5gXYeVGPDcB0lvap0gLQshkh1Nyel1QazC+wNxus3xPlsYAgqU1BUmrmCvWtw==}
     engines: {node: '>=12'}
@@ -7586,6 +7772,14 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@esbuild/win32-x64@0.17.18:
+    resolution: {integrity: sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [win32]
+    requiresBuild: true
+    optional: true
+
   /@eslint-community/eslint-utils@4.4.0(eslint@8.38.0):
     resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -11335,6 +11529,35 @@ packages:
       '@esbuild/win32-ia32': 0.17.12
       '@esbuild/win32-x64': 0.17.12
 
+  /esbuild@0.17.18:
+    resolution: {integrity: sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==}
+    engines: {node: '>=12'}
+    hasBin: true
+    requiresBuild: true
+    optionalDependencies:
+      '@esbuild/android-arm': 0.17.18
+      '@esbuild/android-arm64': 0.17.18
+      '@esbuild/android-x64': 0.17.18
+      '@esbuild/darwin-arm64': 0.17.18
+      '@esbuild/darwin-x64': 0.17.18
+      '@esbuild/freebsd-arm64': 0.17.18
+      '@esbuild/freebsd-x64': 0.17.18
+      '@esbuild/linux-arm': 0.17.18
+      '@esbuild/linux-arm64': 0.17.18
+      '@esbuild/linux-ia32': 0.17.18
+      '@esbuild/linux-loong64': 0.17.18
+      '@esbuild/linux-mips64el': 0.17.18
+      '@esbuild/linux-ppc64': 0.17.18
+      '@esbuild/linux-riscv64': 0.17.18
+      '@esbuild/linux-s390x': 0.17.18
+      '@esbuild/linux-x64': 0.17.18
+      '@esbuild/netbsd-x64': 0.17.18
+      '@esbuild/openbsd-x64': 0.17.18
+      '@esbuild/sunos-x64': 0.17.18
+      '@esbuild/win32-arm64': 0.17.18
+      '@esbuild/win32-ia32': 0.17.18
+      '@esbuild/win32-x64': 0.17.18
+
   /escalade@3.1.1:
     resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
     engines: {node: '>=6'}
@@ -17392,7 +17615,7 @@ packages:
         optional: true
     dependencies:
       '@types/node': 18.16.3
-      esbuild: 0.17.12
+      esbuild: 0.17.18
       postcss: 8.4.23
       rollup: 3.21.8
       sass: 1.52.2
diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js
index bb8220eef165..99208a29f257 100644
--- a/scripts/cmd/prebuild.js
+++ b/scripts/cmd/prebuild.js
@@ -1,4 +1,5 @@
 import esbuild from 'esbuild';
+import { red } from 'kleur/colors';
 import glob from 'tiny-glob';
 import fs from 'fs';
 import path from 'path';
@@ -39,11 +40,40 @@ export default async function prebuild(...args) {
 	}
 
 	async function prebuildFile(filepath) {
-		const tscode = await fs.promises.readFile(filepath, 'utf-8');
-		const esbuildresult = await esbuild.transform(tscode, {
-			loader: 'ts',
+		let tscode = await fs.promises.readFile(filepath, 'utf-8');
+		// If we're bundling a client directive, modify the code to match `packages/astro/src/core/client-directive/build.ts`.
+		// If updating this code, make sure to also update that file.
+		if (filepath.includes(`runtime${path.sep}client`)) {
+			// `export default xxxDirective` is a convention used in the current client directives that we use
+			// to make sure we bundle this right. We'll error below if this convention isn't followed.
+			const newTscode = tscode.replace(
+				/export default (.*?)Directive/,
+				(_, name) =>
+					`(self.Astro || (self.Astro = {})).${name} = ${name}Directive;window.dispatchEvent(new Event('astro:${name}'))`
+			);
+			if (newTscode === tscode) {
+				console.error(
+					red(
+						`${filepath} doesn't follow the \`export default xxxDirective\` convention. The prebuilt output may be wrong. ` +
+							`For more information, check out ${fileURLToPath(import.meta.url)}`
+					)
+				);
+			}
+			tscode = newTscode;
+		}
+		const esbuildresult = await esbuild.build({
+			stdin: {
+				contents: tscode,
+				resolveDir: path.dirname(filepath),
+				loader: 'ts',
+				sourcefile: filepath,
+			},
+			format: 'iife',
 			minify,
+			bundle: true,
+			write: false,
 		});
+		const code = esbuildresult.outputFiles[0].text.trim();
 		const rootURL = new URL('../../', import.meta.url);
 		const rel = path.relative(fileURLToPath(rootURL), filepath);
 		const mod = `/**
@@ -52,7 +82,7 @@ export default async function prebuild(...args) {
  * to generate this file.
  */
 
-export default \`${escapeTemplateLiterals(esbuildresult.code.trim())}\`;`;
+export default \`${escapeTemplateLiterals(code)}\`;`;
 		const url = getPrebuildURL(filepath);
 		await fs.promises.writeFile(url, mod, 'utf-8');
 	}