diff --git a/.changeset/sour-games-boil.md b/.changeset/sour-games-boil.md
new file mode 100644
index 0000000000..9ed1f880d1
--- /dev/null
+++ b/.changeset/sour-games-boil.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Adds support for hoisted scripts to the static build
diff --git a/examples/fast-build/package.json b/examples/fast-build/package.json
index 1fcdc03b20..a56885d52f 100644
--- a/examples/fast-build/package.json
+++ b/examples/fast-build/package.json
@@ -11,6 +11,7 @@
   },
   "devDependencies": {
     "astro": "^0.22.16",
+    "preact": "~10.5.15",
     "unocss": "^0.15.5",
     "vite-imagetools": "^4.0.1"
   }
diff --git a/examples/fast-build/src/components/ExternalHoisted.astro b/examples/fast-build/src/components/ExternalHoisted.astro
new file mode 100644
index 0000000000..a9e7d2ae2d
--- /dev/null
+++ b/examples/fast-build/src/components/ExternalHoisted.astro
@@ -0,0 +1,2 @@
+<div id="external-hoist"></div>
+<script type="module" hoist src="/src/scripts/external-hoist"></script>
diff --git a/examples/fast-build/src/components/InlineHoisted.astro b/examples/fast-build/src/components/InlineHoisted.astro
new file mode 100644
index 0000000000..ba6c0ab4d4
--- /dev/null
+++ b/examples/fast-build/src/components/InlineHoisted.astro
@@ -0,0 +1,13 @@
+<script type="module" hoist>
+	import { h, render } from 'preact';
+
+
+	const mount = document.querySelector('#inline-hoist');
+
+	function App() {
+		return h('strong', null, 'Hello again');
+	}
+
+	render(h(App), mount);
+</script>
+<div id="inline-hoist"></div>
diff --git a/examples/fast-build/src/pages/index.astro b/examples/fast-build/src/pages/index.astro
index 9d4555b79c..ef0136b277 100644
--- a/examples/fast-build/src/pages/index.astro
+++ b/examples/fast-build/src/pages/index.astro
@@ -4,6 +4,8 @@ import grayscaleUrl from '../images/random.jpg?grayscale=true';
 import Greeting from '../components/Greeting.vue';
 import Counter from '../components/Counter.vue';
 import { Code } from 'astro/components';
+import InlineHoisted from '../components/InlineHoisted.astro';
+import ExternalHoisted from '../components/ExternalHoisted.astro';
 ---
 
 <html>
@@ -44,5 +46,11 @@ import { Code } from 'astro/components';
     <h1>Hydrated component</h1>
     <Counter client:idle />
   </section>
+
+	<section>
+		<h1>Hoisted scripts</h1>
+		<InlineHoisted />
+		<ExternalHoisted />
+	</section>
 </body>
 </html>
diff --git a/examples/fast-build/src/scripts/external-hoist.ts b/examples/fast-build/src/scripts/external-hoist.ts
new file mode 100644
index 0000000000..ff7ee0bcfd
--- /dev/null
+++ b/examples/fast-build/src/scripts/external-hoist.ts
@@ -0,0 +1,2 @@
+const el = document.querySelector('#external-hoist');
+el.textContent = `This was loaded externally`;
diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts
index ee379f4e31..9185e7e89a 100644
--- a/packages/astro/src/core/build/internal.ts
+++ b/packages/astro/src/core/build/internal.ts
@@ -15,6 +15,9 @@ export interface BuildInternals {
 	// A mapping to entrypoints (facadeId) to assets (styles) that are added.
 	facadeIdToAssetsMap: Map<string, string[]>;
 
+	hoistedScriptIdToHoistedMap: Map<string, Set<string>>;
+	facadeIdToHoistedEntryMap: Map<string, string>;
+
 	// A mapping of specifiers like astro/client/idle.js to the hashed bundled name.
 	// Used to render pages with the correct specifiers.
 	entrySpecifierToBundleMap: Map<string, string>;
@@ -39,12 +42,18 @@ export function createBuildInternals(): BuildInternals {
 	// A mapping to entrypoints (facadeId) to assets (styles) that are added.
 	const facadeIdToAssetsMap = new Map<string, string[]>();
 
+	// These are for tracking hoisted script bundling
+	const hoistedScriptIdToHoistedMap = new Map<string, Set<string>>();
+	const facadeIdToHoistedEntryMap = new Map<string, string>();
+
 	return {
 		pureCSSChunks,
 		chunkToReferenceIdMap,
 		astroStyleMap,
 		astroPageStyleMap,
 		facadeIdToAssetsMap,
+		hoistedScriptIdToHoistedMap,
+		facadeIdToHoistedEntryMap,
 		entrySpecifierToBundleMap: new Map<string, string>(),
 	};
 }
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
index 1ccc005065..3f4089e4f4 100644
--- a/packages/astro/src/core/build/static-build.ts
+++ b/packages/astro/src/core/build/static-build.ts
@@ -20,6 +20,7 @@ import { getParamsAndProps } from '../ssr/index.js';
 import { createResult } from '../ssr/result.js';
 import { renderPage } from '../../runtime/server/index.js';
 import { prepareOutDir } from './fs.js';
+import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js';
 
 export interface StaticBuildOptions {
 	allPages: AllPagesData;
@@ -70,6 +71,12 @@ function* throttle(max: number, inPaths: string[]) {
 	}
 }
 
+function getByFacadeId<T>(facadeId: string, map: Map<string, T>): T | undefined {
+	return map.get(facadeId) ||
+		// Check with a leading `/` because on Windows it doesn't have one.
+		map.get('/' + facadeId);
+}
+
 export async function staticBuild(opts: StaticBuildOptions) {
 	const { allPages, astroConfig } = opts;
 
@@ -91,7 +98,12 @@ export async function staticBuild(opts: StaticBuildOptions) {
 		jsInput.add(polyfill);
 	}
 
+	// Build internals needed by the CSS plugin
+	const internals = createBuildInternals();
+
 	for (const [component, pageData] of Object.entries(allPages)) {
+		const astroModuleURL = new URL('./' + component, astroConfig.projectRoot);
+		const astroModuleId = astroModuleURL.pathname;
 		const [renderers, mod] = pageData.preload;
 		const metadata = mod.$$metadata;
 
@@ -104,18 +116,23 @@ export async function staticBuild(opts: StaticBuildOptions) {
 			...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!),
 		]);
 
+		// Add hoisted scripts
+		const hoistedScripts = new Set(metadata.hoistedScriptPaths());
+		if(hoistedScripts.size) {
+			const moduleId = new URL('./hoisted.js', astroModuleURL + '/').pathname;
+			internals.hoistedScriptIdToHoistedMap.set(moduleId, hoistedScripts);
+			topLevelImports.add(moduleId);
+		}
+
 		for (const specifier of topLevelImports) {
 			jsInput.add(specifier);
 		}
 
-		let astroModuleId = new URL('./' + component, astroConfig.projectRoot).pathname;
+		
 		pageInput.add(astroModuleId);
 		facadeIdToPageDataMap.set(astroModuleId, pageData);
 	}
 
-	// Build internals needed by the CSS plugin
-	const internals = createBuildInternals();
-
 	// Empty out the dist folder, if needed. Vite has a config for doing this
 	// but because we are running 2 vite builds in parallel, that would cause a race
 	// condition, so we are doing it ourselves
@@ -189,6 +206,7 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals,
 		},
 		plugins: [
 			vitePluginNewBuild(input, internals, 'js'),
+			vitePluginHoistedScripts(internals),
 			rollupPluginAstroBuildCSS({
 				internals,
 			}),
@@ -249,16 +267,14 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter
 
 	let url = new URL('./' + output.fileName, astroConfig.dist);
 	const facadeId: string = output.facadeModuleId as string;
-	let pageData =
-		facadeIdToPageDataMap.get(facadeId) ||
-		// Check with a leading `/` because on Windows it doesn't have one.
-		facadeIdToPageDataMap.get('/' + facadeId);
+	let pageData = getByFacadeId<PageBuildData>(facadeId, facadeIdToPageDataMap);
 
 	if (!pageData) {
 		throw new Error(`Unable to find a PageBuildData for the Astro page: ${facadeId}. There are the PageBuilDatas we have ${Array.from(facadeIdToPageDataMap.keys()).join(', ')}`);
 	}
 
-	let linkIds = internals.facadeIdToAssetsMap.get(facadeId) || [];
+	const linkIds = getByFacadeId<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
+	const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null;
 
 	let compiledModule = await import(url.toString());
 	let Component = compiledModule.default;
@@ -267,6 +283,7 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter
 		pageData,
 		internals,
 		linkIds,
+		hoistedId,
 		Component,
 		renderers,
 	};
@@ -288,13 +305,14 @@ interface GeneratePathOptions {
 	pageData: PageBuildData;
 	internals: BuildInternals;
 	linkIds: string[];
+	hoistedId: string | null;
 	Component: AstroComponentFactory;
 	renderers: Renderer[];
 }
 
 async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) {
-	const { astroConfig, logging, origin, pageNames, routeCache } = opts;
-	const { Component, internals, linkIds, pageData, renderers } = gopts;
+	const { astroConfig, logging, origin, routeCache } = opts;
+	const { Component, internals, linkIds, hoistedId, pageData, renderers } = gopts;
 
 	// This adds the page name to the array so it can be shown as part of stats.
 	addPageName(pathname, opts);
@@ -316,8 +334,7 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
 		debug(logging, 'generate', `Generating: ${pathname}`);
 
 		const rootpath = new URL(astroConfig.buildOptions.site || 'http://localhost/').pathname;
-		const result = createResult({ astroConfig, logging, origin, params, pathname, renderers });
-		result.links = new Set<SSRElement>(
+		const links = new Set<SSRElement>(
 			linkIds.map((href) => ({
 				props: {
 					rel: 'stylesheet',
@@ -326,6 +343,14 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
 				children: '',
 			}))
 		);
+		const scripts = hoistedId ? new Set<SSRElement>([{
+			props: {
+				type: 'module',
+				src: npath.posix.join(rootpath, hoistedId),
+			},
+			children: ''
+		}]) : new Set<SSRElement>();
+		const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, links, scripts });
 
 		// Override the `resolve` method so that hydrated components are given the
 		// hashed filepath to the component.
diff --git a/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts b/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts
new file mode 100644
index 0000000000..8606d6a514
--- /dev/null
+++ b/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts
@@ -0,0 +1,43 @@
+import type { Plugin as VitePlugin } from '../vite';
+import type { BuildInternals } from '../../core/build/internal.js';
+
+function virtualHoistedEntry(id: string) {
+	return id.endsWith('.astro/hoisted.js') || id.endsWith('.md/hoisted.js');
+}
+
+export function vitePluginHoistedScripts(internals: BuildInternals): VitePlugin {
+	return {
+		name: '@astro/rollup-plugin-astro-hoisted-scripts',
+
+		resolveId(id) {
+			if(virtualHoistedEntry(id)) {
+				return id;
+			}
+		},
+
+		load(id) {
+			if(virtualHoistedEntry(id)) {
+				let code = '';
+				for(let path of internals.hoistedScriptIdToHoistedMap.get(id)!) {
+					code += `import "${path}";`
+				}
+				return {
+					code
+				};
+			}
+			return void 0;
+		},
+
+		async generateBundle(_options, bundle) {
+			// Find all page entry points and create a map of the entry point to the hashed hoisted script.
+			// This is used when we render so that we can add the script to the head.
+			for(const [id, output] of Object.entries(bundle)) {
+				if(output.type === 'chunk' && output.facadeModuleId && virtualHoistedEntry(output.facadeModuleId)) {
+					const facadeId = output.facadeModuleId!;
+					const filename = facadeId.slice(0, facadeId.length - "/hoisted.js".length);
+					internals.facadeIdToHoistedEntryMap.set(filename, id);
+				}
+			}
+		}
+	};
+}
diff --git a/packages/astro/src/core/ssr/index.ts b/packages/astro/src/core/ssr/index.ts
index daa6272203..07a5bc7196 100644
--- a/packages/astro/src/core/ssr/index.ts
+++ b/packages/astro/src/core/ssr/index.ts
@@ -219,7 +219,14 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
 	if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
 	if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`);
 
-	const result = createResult({ astroConfig, logging, origin, params, pathname, renderers });
+	// Add hoisted script tags
+	const scripts = astroConfig.buildOptions.experimentalStaticBuild ?
+		new Set<SSRElement>(Array.from(mod.$$metadata.hoistedScriptPaths()).map(src => ({
+			props: { type: 'module', src },
+			children: ''
+		}))) : new Set<SSRElement>();
+
+	const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, scripts });
 	// Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js"
 	result.resolve = async (s: string) => {
 		// The legacy build needs these to remain unresolved so that vite HTML
diff --git a/packages/astro/src/core/ssr/result.ts b/packages/astro/src/core/ssr/result.ts
index 4c8c965550..7aca848a2e 100644
--- a/packages/astro/src/core/ssr/result.ts
+++ b/packages/astro/src/core/ssr/result.ts
@@ -13,6 +13,8 @@ export interface CreateResultArgs {
 	params: Params;
 	pathname: string;
 	renderers: Renderer[];
+	links?: Set<SSRElement>;
+	scripts?: Set<SSRElement>;
 }
 
 export function createResult(args: CreateResultArgs): SSRResult {
@@ -23,8 +25,8 @@ export function createResult(args: CreateResultArgs): SSRResult {
 	// calling the render() function will populate the object with scripts, styles, etc.
 	const result: SSRResult = {
 		styles: new Set<SSRElement>(),
-		scripts: new Set<SSRElement>(),
-		links: new Set<SSRElement>(),
+		scripts: args.scripts ?? new Set<SSRElement>(),
+		links: args.links ?? new Set<SSRElement>(),
 		/** This function returns the `Astro` faux-global */
 		createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) {
 			const site = new URL(origin);
diff --git a/packages/astro/src/runtime/server/metadata.ts b/packages/astro/src/runtime/server/metadata.ts
index 6d9d017d52..dc6a9a3a75 100644
--- a/packages/astro/src/runtime/server/metadata.ts
+++ b/packages/astro/src/runtime/server/metadata.ts
@@ -18,7 +18,7 @@ interface CreateMetadataOptions {
 }
 
 export class Metadata {
-	public fileURL: URL;
+	public mockURL: URL;
 	public modules: ModuleInfo[];
 	public hoisted: any[];
 	public hydratedComponents: any[];
@@ -31,12 +31,12 @@ export class Metadata {
 		this.hoisted = opts.hoisted;
 		this.hydratedComponents = opts.hydratedComponents;
 		this.hydrationDirectives = opts.hydrationDirectives;
-		this.fileURL = new URL(filePathname, 'http://example.com');
+		this.mockURL = new URL(filePathname, 'http://example.com');
 		this.metadataCache = new Map<any, ComponentMetadata | null>();
 	}
 
 	resolvePath(specifier: string): string {
-		return specifier.startsWith('.') ? new URL(specifier, this.fileURL).pathname : specifier;
+		return specifier.startsWith('.') ? new URL(specifier, this.mockURL).pathname : specifier;
 	}
 
 	getPath(Component: any): string | null {
@@ -81,6 +81,16 @@ export class Metadata {
 		}
 	}
 
+	* hoistedScriptPaths() {
+		for(const metadata of this.deepMetadata()) {
+			let i = 0, pathname = metadata.mockURL.pathname;
+			while(i < metadata.hoisted.length) {
+				yield `${pathname}?astro&type=script&index=${i}`;
+				i++;
+			}
+		}
+	}
+
 	private *deepMetadata(): Generator<Metadata, void, unknown> {
 		// Yield self
 		yield this;
diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts
index 2c1b9693d7..826028d7b8 100644
--- a/packages/astro/src/vite-plugin-astro/index.ts
+++ b/packages/astro/src/vite-plugin-astro/index.ts
@@ -19,6 +19,15 @@ interface AstroPluginOptions {
 
 /** Transform .astro files for Vite */
 export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin {
+	function normalizeFilename(filename: string) {
+		if (filename.startsWith('/@fs')) {
+			filename = filename.slice('/@fs'.length);
+		} else if (filename.startsWith('/') && !ancestor(filename, config.projectRoot.pathname)) {
+			filename = new URL('.' + filename, config.projectRoot).pathname;
+		}
+		return filename;
+	}
+
 	let viteTransform: TransformHook;
 	return {
 		name: '@astrojs/vite-plugin-astro',
@@ -37,23 +46,37 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
 			let { filename, query } = parseAstroRequest(id);
 			if (query.astro) {
 				if (query.type === 'style') {
-					if (filename.startsWith('/@fs')) {
-						filename = filename.slice('/@fs'.length);
-					} else if (filename.startsWith('/') && !ancestor(filename, config.projectRoot.pathname)) {
-						filename = new URL('.' + filename, config.projectRoot).pathname;
-					}
-					const transformResult = await cachedCompilation(config, filename, null, viteTransform, opts);
-
 					if (typeof query.index === 'undefined') {
 						throw new Error(`Requests for Astro CSS must include an index.`);
 					}
 
+					const transformResult = await cachedCompilation(config,
+						normalizeFilename(filename), null, viteTransform, opts);
 					const csses = transformResult.css;
 					const code = csses[query.index];
 
 					return {
 						code,
 					};
+				} else if(query.type === 'script') {
+					if(typeof query.index === 'undefined') {
+						throw new Error(`Requests for hoisted scripts must include an index`);
+					}
+
+					const transformResult = await cachedCompilation(config,
+						normalizeFilename(filename), null, viteTransform, opts);
+					const scripts = transformResult.scripts;
+					const hoistedScript = scripts[query.index];
+
+					if(!hoistedScript) {
+						throw new Error(`No hoisted script at index ${query.index}`);
+					}
+
+					return {
+						code: hoistedScript.type === 'inline' ?
+							hoistedScript.code! :
+							`import "${hoistedScript.src!}";`
+					};
 				}
 			}
 
diff --git a/packages/astro/test/fixtures/static-build/src/components/ExternalHoisted.astro b/packages/astro/test/fixtures/static-build/src/components/ExternalHoisted.astro
new file mode 100644
index 0000000000..a9e7d2ae2d
--- /dev/null
+++ b/packages/astro/test/fixtures/static-build/src/components/ExternalHoisted.astro
@@ -0,0 +1,2 @@
+<div id="external-hoist"></div>
+<script type="module" hoist src="/src/scripts/external-hoist"></script>
diff --git a/packages/astro/test/fixtures/static-build/src/components/InlineHoisted.astro b/packages/astro/test/fixtures/static-build/src/components/InlineHoisted.astro
new file mode 100644
index 0000000000..ba6c0ab4d4
--- /dev/null
+++ b/packages/astro/test/fixtures/static-build/src/components/InlineHoisted.astro
@@ -0,0 +1,13 @@
+<script type="module" hoist>
+	import { h, render } from 'preact';
+
+
+	const mount = document.querySelector('#inline-hoist');
+
+	function App() {
+		return h('strong', null, 'Hello again');
+	}
+
+	render(h(App), mount);
+</script>
+<div id="inline-hoist"></div>
diff --git a/packages/astro/test/fixtures/static-build/src/pages/hoisted.astro b/packages/astro/test/fixtures/static-build/src/pages/hoisted.astro
new file mode 100644
index 0000000000..9677a6c52b
--- /dev/null
+++ b/packages/astro/test/fixtures/static-build/src/pages/hoisted.astro
@@ -0,0 +1,17 @@
+---
+import InlineHoisted from '../components/InlineHoisted.astro';
+import ExternalHoisted from '../components/ExternalHoisted.astro';
+---
+
+<html>
+	<head>
+		<title>Demo app</title>
+	</head>
+	<body>
+	<section>
+		<h1>Hoisted scripts</h1>
+		<InlineHoisted />
+		<ExternalHoisted />
+	</section>
+</body>
+</html>
diff --git a/packages/astro/test/fixtures/static-build/src/scripts/external-hoist.ts b/packages/astro/test/fixtures/static-build/src/scripts/external-hoist.ts
new file mode 100644
index 0000000000..ca6112cd3a
--- /dev/null
+++ b/packages/astro/test/fixtures/static-build/src/scripts/external-hoist.ts
@@ -0,0 +1,2 @@
+const element: HTMLElement = document.querySelector('#external-hoist');
+element.textContent = `This was loaded externally`;
diff --git a/packages/astro/test/static-build.test.js b/packages/astro/test/static-build.test.js
index 6f0ce96470..02d4f6c807 100644
--- a/packages/astro/test/static-build.test.js
+++ b/packages/astro/test/static-build.test.js
@@ -77,4 +77,21 @@ describe('Static build', () => {
 			expect(found).to.equal(true, 'Did not find shared CSS module code');
 		});
 	});
+
+	describe('Hoisted scripts', () => {
+		it('Get bundled together on the page', async () => {
+			const html = await fixture.readFile('/hoisted/index.html');
+			const $ = cheerio.load(html);
+			expect($('script[type="module"]').length).to.equal(1, 'hoisted script added');
+		});
+
+		it('Do not get added to the wrong page', async () => {
+			const hoistedHTML = await fixture.readFile('/hoisted/index.html');
+			const $ = cheerio.load(hoistedHTML);
+			const href = $('script[type="module"]').attr('src');
+			const indexHTML = await fixture.readFile('/index.html');
+			const $$ = cheerio.load(indexHTML);
+			expect($$(`script[src="${href}"]`).length).to.equal(0, 'no script added to different page');
+		})
+	});
 });