Repository: sveltejs/kit
Pull request #5141 (2022-06-01 23:48:25)

[chore] introduce `KitConfig` type
Creates a `KitConfig` and `ValidatedKitConfig` (last file in the PR) to start to decouple the Svelte and SvelteKit configuration. We will need to figure out what to do about `extensions` which stops us from being able to more thoroughly convert to `ValidatedKitConfig`

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 25dcfcca0296c561004ba90b407fc7bcfd060029

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/sveltejs/kit/new/kit-config?filename=.changeset/moody-lobsters-cough.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A---%0A%0A%5Bchore%5D%20introduce%20KitConfig%20type%0A)


Rich-Harris: Should we change `KitConfig` to `UserConfig`? It aligns with Vite, and this...

```js
/** @type {import('@sveltejs/kit').UserConfig} */
```

...feels a bit more natural than this, to me:

```js
/** @type {import('@sveltejs/kit').KitConfig} */
```

Presumably `ValidatedKitConfig` will be renamed to `ValidatedConfig` if we get rid of the current `ValidatedConfig`, but that's a task for future us

Patch:
diff --git a/.changeset/empty-doors-count.md b/.changeset/empty-doors-count.md
new file mode 100644
index 000000000..b65c1cb3f
--- /dev/null
+++ b/.changeset/empty-doors-count.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Disallow access to `__data.json` for standalone endpoints
diff --git a/.changeset/moody-lobsters-cough.md b/.changeset/moody-lobsters-cough.md
new file mode 100644
index 000000000..197424db7
--- /dev/null
+++ b/.changeset/moody-lobsters-cough.md
@@ -0,0 +1,5 @@
+---
+"@sveltejs/kit": patch
+---
+
+[chore] introduce `KitConfig` type
diff --git a/.changeset/old-beers-push.md b/.changeset/old-beers-push.md
new file mode 100644
index 000000000..e4da3a66c
--- /dev/null
+++ b/.changeset/old-beers-push.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Prevent incorrect redirect for `__data.json` request with `trailingSlash: 'always'`
diff --git a/packages/kit/src/core/build/build_server.js b/packages/kit/src/core/build/build_server.js
index f1b50f5af..ad5ac851b 100644
--- a/packages/kit/src/core/build/build_server.js
+++ b/packages/kit/src/core/build/build_server.js
@@ -169,7 +169,7 @@ export async function build_server(options, client) {
 			config,
 			hooks: app_relative(hooks_file),
 			has_service_worker: config.kit.serviceWorker.register && !!service_worker_entry_file,
-			runtime: get_runtime_path(config),
+			runtime: get_runtime_path(config.kit),
 			template: load_template(cwd, config)
 		})
 	);
diff --git a/packages/kit/src/core/build/build_service_worker.js b/packages/kit/src/core/build/build_service_worker.js
index 07a188b6a..f10adf0da 100644
--- a/packages/kit/src/core/build/build_service_worker.js
+++ b/packages/kit/src/core/build/build_service_worker.js
@@ -68,7 +68,7 @@ export async function build_service_worker(
 
 	/** @type {[any, string[]]} */
 	const [merged_config, conflicts] = deep_merge(await config.kit.vite(), {
-		base: assets_base(config),
+		base: assets_base(config.kit),
 		build: {
 			lib: {
 				entry: service_worker_entry_file,
diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js
index ace5c1e4d..5868a66e3 100644
--- a/packages/kit/src/core/build/index.js
+++ b/packages/kit/src/core/build/index.js
@@ -32,7 +32,7 @@ export async function build(config, { log }) {
 		build_dir,
 		manifest_data,
 		output_dir,
-		client_entry_file: path.relative(cwd, `${get_runtime_path(config)}/client/start.js`),
+		client_entry_file: path.relative(cwd, `${get_runtime_path(config.kit)}/client/start.js`),
 		service_worker_entry_file: resolve_entry(config.kit.files.serviceWorker)
 	};
 
@@ -71,7 +71,7 @@ export async function build(config, { log }) {
 	});
 
 	const prerendered = await prerender({
-		config,
+		config: config.kit,
 		entries: options.manifest_data.routes
 			.map((route) => (route.type === 'page' ? route.path : ''))
 			.filter(Boolean),
diff --git a/packages/kit/src/core/build/prerender/prerender.js b/packages/kit/src/core/build/prerender/prerender.js
index 92f536c01..661cb496e 100644
--- a/packages/kit/src/core/build/prerender/prerender.js
+++ b/packages/kit/src/core/build/prerender/prerender.js
@@ -40,7 +40,7 @@ const REDIRECT = 3;
 
 /**
  * @param {{
- *   config: import('types').ValidatedConfig;
+ *   config: import('types').ValidatedKitConfig;
  *   entries: string[];
  *   files: Set<string>;
  *   log: Logger;
@@ -55,36 +55,36 @@ export async function prerender({ config, entries, files, log }) {
 		paths: []
 	};
 
-	if (!config.kit.prerender.enabled) {
+	if (!config.prerender.enabled) {
 		return prerendered;
 	}
 
 	installPolyfills();
 
-	const server_root = join(config.kit.outDir, 'output');
+	const server_root = join(config.outDir, 'output');
 
 	/** @type {import('types').ServerModule} */
 	const { Server, override } = await import(pathToFileURL(`${server_root}/server/index.js`).href);
 	const { manifest } = await import(pathToFileURL(`${server_root}/server/manifest.js`).href);
 
 	override({
-		paths: config.kit.paths,
+		paths: config.paths,
 		prerendering: true,
-		read: (file) => readFileSync(join(config.kit.files.assets, file))
+		read: (file) => readFileSync(join(config.files.assets, file))
 	});
 
 	const server = new Server(manifest);
 
-	const error = normalise_error_handler(log, config.kit.prerender.onError);
+	const error = normalise_error_handler(log, config.prerender.onError);
 
-	const q = queue(config.kit.prerender.concurrency);
+	const q = queue(config.prerender.concurrency);
 
 	/**
 	 * @param {string} path
 	 * @param {boolean} is_html
 	 */
 	function output_filename(path, is_html) {
-		const file = path.slice(config.kit.paths.base.length + 1);
+		const file = path.slice(config.paths.base.length + 1);
 
 		if (file === '') {
 			return 'index.html';
@@ -109,7 +109,7 @@ export async function prerender({ config, entries, files, log }) {
 		if (seen.has(decoded)) return;
 		seen.add(decoded);
 
-		const file = decoded.slice(config.kit.paths.base.length + 1);
+		const file = decoded.slice(config.paths.base.length + 1);
 		if (files.has(file)) return;
 
 		return q.add(() => visit(decoded, encoded || encodeURI(decoded), referrer));
@@ -121,7 +121,7 @@ export async function prerender({ config, entries, files, log }) {
 	 * @param {string?} referrer
 	 */
 	async function visit(decoded, encoded, referrer) {
-		if (!decoded.startsWith(config.kit.paths.base)) {
+		if (!decoded.startsWith(config.paths.base)) {
 			error({ status: 404, path: decoded, referrer, referenceType: 'linked' });
 			return;
 		}
@@ -158,7 +158,7 @@ export async function prerender({ config, entries, files, log }) {
 			);
 		}
 
-		if (config.kit.prerender.crawl && response.headers.get('content-type') === 'text/html') {
+		if (config.prerender.crawl && response.headers.get('content-type') === 'text/html') {
 			for (const href of crawl(text)) {
 				if (href.startsWith('data:') || href.startsWith('#')) continue;
 
@@ -191,7 +191,7 @@ export async function prerender({ config, entries, files, log }) {
 		const is_html = response_type === REDIRECT || type === 'text/html';
 
 		const file = output_filename(decoded, is_html);
-		const dest = `${config.kit.outDir}/output/prerendered/${category}/${file}`;
+		const dest = `${config.outDir}/output/prerendered/${category}/${file}`;
 
 		if (written.has(file)) return;
 
@@ -255,14 +255,14 @@ export async function prerender({ config, entries, files, log }) {
 		}
 	}
 
-	if (config.kit.prerender.enabled) {
-		for (const entry of config.kit.prerender.entries) {
+	if (config.prerender.enabled) {
+		for (const entry of config.prerender.entries) {
 			if (entry === '*') {
 				for (const entry of entries) {
-					enqueue(null, config.kit.paths.base + entry); // TODO can we pre-normalize these?
+					enqueue(null, config.paths.base + entry); // TODO can we pre-normalize these?
 				}
 			} else {
-				enqueue(null, config.kit.paths.base + entry);
+				enqueue(null, config.paths.base + entry);
 			}
 		}
 
@@ -277,7 +277,7 @@ export async function prerender({ config, entries, files, log }) {
 		}
 	});
 
-	const file = `${config.kit.outDir}/output/prerendered/fallback.html`;
+	const file = `${config.outDir}/output/prerendered/fallback.html`;
 	mkdirp(dirname(file));
 	writeFileSync(file, await rendered.text());
 
diff --git a/packages/kit/src/core/build/utils.js b/packages/kit/src/core/build/utils.js
index 6a0477b9d..57199c9b4 100644
--- a/packages/kit/src/core/build/utils.js
+++ b/packages/kit/src/core/build/utils.js
@@ -56,7 +56,7 @@ export function find_deps(file, manifest, js, css) {
  */
 export const get_default_config = function ({ client_out_dir, config, input, output_dir, ssr }) {
 	return {
-		base: assets_base(config),
+		base: assets_base(config.kit),
 		build: {
 			cssCodeSplit: true,
 			manifest: true,
@@ -88,19 +88,19 @@ export const get_default_config = function ({ client_out_dir, config, input, out
 		// if it happens to be 'public' instead of 'static'
 		publicDir: false,
 		resolve: {
-			alias: get_aliases(config)
+			alias: get_aliases(config.kit)
 		}
 	};
 };
 
 /**
- * @param {import('types').ValidatedConfig} config
+ * @param {import('types').ValidatedKitConfig} config
  * @returns {string}
  */
 export function assets_base(config) {
 	// TODO this is so that Vite's preloading works. Unfortunately, it fails
 	// during `svelte-kit preview`, because we use a local asset path. This
 	// may be fixed in Vite 3: https://github.com/vitejs/vite/issues/2009
-	const { base, assets } = config.kit.paths;
-	return `${assets || base}/${config.kit.appDir}/immutable/`;
+	const { base, assets } = config.paths;
+	return `${assets || base}/${config.appDir}/immutable/`;
 }
diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js
index 94bc8bd4f..0736eaca4 100644
--- a/packages/kit/src/core/dev/plugin.js
+++ b/packages/kit/src/core/dev/plugin.js
@@ -27,6 +27,7 @@ const cwd = process.cwd();
  * @return {import('vite').Plugin}
  */
 export const sveltekit = function (svelte_config) {
+	const kit_config = svelte_config.kit;
 	return {
 		name: 'vite-plugin-svelte-kit',
 
@@ -37,9 +38,9 @@ export const sveltekit = function (svelte_config) {
 						fs: {
 							allow: [
 								...new Set([
-									svelte_config.kit.files.lib,
-									svelte_config.kit.files.routes,
-									svelte_config.kit.outDir,
+									kit_config.files.lib,
+									kit_config.files.routes,
+									kit_config.outDir,
 									path.resolve(cwd, 'src'),
 									path.resolve(cwd, 'node_modules'),
 									path.resolve(searchForWorkspaceRoot(cwd), 'node_modules')
@@ -49,14 +50,11 @@ export const sveltekit = function (svelte_config) {
 						port: 3000,
 						strictPort: true,
 						watch: {
-							ignored: [
-								`${svelte_config.kit.outDir}/**`,
-								`!${svelte_config.kit.outDir}/generated/**`
-							]
+							ignored: [`${kit_config.outDir}/**`, `!${kit_config.outDir}/generated/**`]
 						}
 					}
 				},
-				await svelte_config.kit.vite()
+				await kit_config.vite()
 			);
 
 			/** @type {[any, string[]]} */
@@ -64,13 +62,13 @@ export const sveltekit = function (svelte_config) {
 				configFile: false,
 				root: cwd,
 				resolve: {
-					alias: get_aliases(svelte_config)
+					alias: get_aliases(kit_config)
 				},
 				build: {
 					rollupOptions: {
 						// Vite dependency crawler needs an explicit JS entry point
 						// eventhough server otherwise works without it
-						input: `${get_runtime_path(svelte_config)}/client/start.js`
+						input: `${get_runtime_path(kit_config)}/client/start.js`
 					}
 				},
 				base: '/'
@@ -86,7 +84,7 @@ export const sveltekit = function (svelte_config) {
 
 			sync.init(svelte_config);
 
-			const runtime = get_runtime_path(svelte_config);
+			const runtime = get_runtime_path(kit_config);
 
 			process.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL = '0';
 
@@ -100,7 +98,7 @@ export const sveltekit = function (svelte_config) {
 				const { manifest_data } = sync.update(svelte_config);
 
 				manifest = {
-					appDir: svelte_config.kit.appDir,
+					appDir: kit_config.appDir,
 					assets: new Set(manifest_data.assets.map((asset) => asset.file)),
 					mimeTypes: get_mime_lookup(manifest_data),
 					_: {
@@ -220,16 +218,14 @@ export const sveltekit = function (svelte_config) {
 
 			for (const event of ['add', 'unlink']) {
 				vite.watcher.on(event, (file) => {
-					if (file.startsWith(svelte_config.kit.files.routes + path.sep)) {
+					if (file.startsWith(kit_config.files.routes + path.sep)) {
 						update_manifest();
 					}
 				});
 			}
 
-			const assets = svelte_config.kit.paths.assets
-				? SVELTE_KIT_ASSETS
-				: svelte_config.kit.paths.base;
-			const asset_server = sirv(svelte_config.kit.files.assets, {
+			const assets = kit_config.paths.assets ? SVELTE_KIT_ASSETS : kit_config.paths.base;
+			const asset_server = sirv(kit_config.files.assets, {
 				dev: true,
 				etag: true,
 				maxAge: 0,
diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js
index 76d555072..a9ac61096 100644
--- a/packages/kit/src/core/sync/create_manifest_data/index.js
+++ b/packages/kit/src/core/sync/create_manifest_data/index.js
@@ -58,7 +58,7 @@ const DEFAULT = 'default';
  */
 export default function create_manifest_data({
 	config,
-	fallback = `${get_runtime_path(config)}/components`,
+	fallback = `${get_runtime_path(config.kit)}/components`,
 	cwd = process.cwd()
 }) {
 	/** @type {import('types').RouteData[]} */
diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js
index 82fb6f049..b88af92c7 100644
--- a/packages/kit/src/core/sync/sync.js
+++ b/packages/kit/src/core/sync/sync.js
@@ -10,7 +10,7 @@ import { write_types } from './write_types.js';
 /** @param {import('types').ValidatedConfig} config */
 export function init(config) {
 	copy_assets(path.join(config.kit.outDir, 'runtime'));
-	write_tsconfig(config);
+	write_tsconfig(config.kit);
 }
 
 /** @param {import('types').ValidatedConfig} config */
diff --git a/packages/kit/src/core/sync/write_tsconfig.js b/packages/kit/src/core/sync/write_tsconfig.js
index e35f717ed..523bde527 100644
--- a/packages/kit/src/core/sync/write_tsconfig.js
+++ b/packages/kit/src/core/sync/write_tsconfig.js
@@ -7,9 +7,9 @@ import { write_if_changed } from './utils.js';
 /** @param {string} file */
 const exists = (file) => fs.existsSync(file) && file;
 
-/** @param {import('types').ValidatedConfig} config */
+/** @param {import('types').ValidatedKitConfig} config */
 export function write_tsconfig(config, cwd = process.cwd()) {
-	const out = path.join(config.kit.outDir, 'tsconfig.json');
+	const out = path.join(config.outDir, 'tsconfig.json');
 	const user_file =
 		exists(path.resolve(cwd, 'tsconfig.json')) || exists(path.resolve(cwd, 'jsconfig.json'));
 
@@ -19,11 +19,11 @@ export function write_tsconfig(config, cwd = process.cwd()) {
 	const project_relative = (file) => posixify(path.relative('.', file));
 
 	/** @param {string} file */
-	const config_relative = (file) => posixify(path.relative(config.kit.outDir, file));
+	const config_relative = (file) => posixify(path.relative(config.outDir, file));
 
 	const dirs = new Set([
-		project_relative(path.dirname(config.kit.files.routes)),
-		project_relative(path.dirname(config.kit.files.lib))
+		project_relative(path.dirname(config.files.routes)),
+		project_relative(path.dirname(config.files.lib))
 	]);
 
 	/** @type {string[]} */
@@ -37,8 +37,8 @@ export function write_tsconfig(config, cwd = process.cwd()) {
 	/** @type {Record<string, string[]>} */
 	const paths = {};
 	const alias = {
-		$lib: project_relative(config.kit.files.lib),
-		...config.kit.alias
+		$lib: project_relative(config.files.lib),
+		...config.alias
 	};
 	for (const [key, value] of Object.entries(alias)) {
 		if (fs.existsSync(project_relative(value))) {
@@ -84,7 +84,7 @@ export function write_tsconfig(config, cwd = process.cwd()) {
 }
 
 /**
- * @param {import('types').ValidatedConfig} config
+ * @param {import('types').ValidatedKitConfig} config
  * @param {string} cwd
  * @param {string} out
  * @param {string} user_file
@@ -103,17 +103,15 @@ function validate(config, cwd, out, user_file) {
 	if (extends_framework_config) {
 		const { paths: user_paths } = user_tsconfig.compilerOptions || {};
 
-		if (user_paths && fs.existsSync(config.kit.files.lib)) {
+		if (user_paths && fs.existsSync(config.files.lib)) {
 			/** @type {string[]} */
 			const lib = user_paths['$lib'] || [];
 			/** @type {string[]} */
 			const lib_ = user_paths['$lib/*'] || [];
 
 			const missing_lib_paths =
-				!lib.some((relative) => path.resolve(cwd, relative) === config.kit.files.lib) ||
-				!lib_.some(
-					(relative) => path.resolve(cwd, relative) === path.join(config.kit.files.lib, '/*')
-				);
+				!lib.some((relative) => path.resolve(cwd, relative) === config.files.lib) ||
+				!lib_.some((relative) => path.resolve(cwd, relative) === path.join(config.files.lib, '/*'));
 
 			if (missing_lib_paths) {
 				console.warn(
@@ -121,7 +119,7 @@ function validate(config, cwd, out, user_file) {
 						.bold()
 						.yellow(`Your compilerOptions.paths in ${kind} should include the following:`)
 				);
-				const relative = posixify(path.relative('.', config.kit.files.lib));
+				const relative = posixify(path.relative('.', config.files.lib));
 				console.warn(`{\n  "$lib":["${relative}"],\n  "$lib/*":["${relative}/*"]\n}`);
 			}
 		}
diff --git a/packages/kit/src/core/utils.js b/packages/kit/src/core/utils.js
index 642a7e7b2..86233a701 100644
--- a/packages/kit/src/core/utils.js
+++ b/packages/kit/src/core/utils.js
@@ -4,8 +4,8 @@ import colors from 'kleur';
 import { fileURLToPath } from 'url';
 
 export const get_runtime_path = process.env.BUNDLED
-	? /** @param {import('types').ValidatedConfig} config */ (config) =>
-			posixify_path(path.join(config.kit.outDir, 'runtime'))
+	? /** @param {import('types').ValidatedKitConfig} config */ (config) =>
+			posixify_path(path.join(config.outDir, 'runtime'))
 	: () => posixify_path(fileURLToPath(new URL('../runtime', import.meta.url)));
 
 /** @param {string} str */
@@ -78,19 +78,19 @@ export function get_mime_lookup(manifest_data) {
 	return mime;
 }
 
-/** @param {import('types').ValidatedConfig} config */
+/** @param {import('types').ValidatedKitConfig} config */
 export function get_aliases(config) {
 	/** @type {Record<string, string>} */
 	const alias = {
-		__GENERATED__: path.posix.join(config.kit.outDir, 'generated'),
+		__GENERATED__: path.posix.join(config.outDir, 'generated'),
 		$app: `${get_runtime_path(config)}/app`,
 
 		// For now, we handle `$lib` specially here rather than make it a default value for
 		// `config.kit.alias` since it has special meaning for packaging, etc.
-		$lib: config.kit.files.lib
+		$lib: config.files.lib
 	};
 
-	for (const [key, value] of Object.entries(config.kit.alias)) {
+	for (const [key, value] of Object.entries(config.alias)) {
 		alias[key] = path.resolve(value);
 	}
 
diff --git a/packages/kit/src/packaging/index.js b/packages/kit/src/packaging/index.js
index 24414d643..e5e60a60a 100644
--- a/packages/kit/src/packaging/index.js
+++ b/packages/kit/src/packaging/index.js
@@ -26,7 +26,7 @@ export async function build(config, cwd = process.cwd()) {
 	mkdirp(dir);
 
 	// Make sure generated tsconfig is up-to-date
-	write_tsconfig(config, cwd);
+	write_tsconfig(config.kit, cwd);
 
 	const files = scan(config);
 
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index d9296f8ea..c10bbacdd 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -59,8 +59,9 @@ export async function respond(request, options, state) {
 	const is_data_request = decoded.endsWith(DATA_SUFFIX);
 
 	if (is_data_request) {
-		decoded = decoded.slice(0, -DATA_SUFFIX.length) || '/';
-		url = new URL(url.origin + url.pathname.slice(0, -DATA_SUFFIX.length) + url.search);
+		const data_suffix_length = DATA_SUFFIX.length - (options.trailing_slash === 'always' ? 1 : 0);
+		decoded = decoded.slice(0, -data_suffix_length) || '/';
+		url = new URL(url.origin + url.pathname.slice(0, -data_suffix_length) + url.search);
 	}
 
 	if (!state.prerendering?.fallback) {
@@ -79,19 +80,26 @@ export async function respond(request, options, state) {
 		}
 	}
 
-	if (route?.type === 'page') {
-		const normalized = normalize_path(url.pathname, options.trailing_slash);
-
-		if (normalized !== url.pathname && !state.prerendering?.fallback) {
+	if (route) {
+		if (route.type === 'page') {
+			const normalized = normalize_path(url.pathname, options.trailing_slash);
+
+			if (normalized !== url.pathname && !state.prerendering?.fallback) {
+				return new Response(undefined, {
+					status: 301,
+					headers: {
+						'x-sveltekit-normalize': '1',
+						location:
+							// ensure paths starting with '//' are not treated as protocol-relative
+							(normalized.startsWith('//') ? url.origin + normalized : normalized) +
+							(url.search === '?' ? '' : url.search)
+					}
+				});
+			}
+		} else if (is_data_request) {
+			// requesting /__data.json should fail for a standalone endpoint
 			return new Response(undefined, {
-				status: 301,
-				headers: {
-					'x-sveltekit-normalize': '1',
-					location:
-						// ensure paths starting with '//' are not treated as protocol-relative
-						(normalized.startsWith('//') ? url.origin + normalized : normalized) +
-						(url.search === '?' ? '' : url.search)
-				}
+				status: 404
 			});
 		}
 	}
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index e1da220c3..66dd74a9f 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -91,68 +91,70 @@ export interface Builder {
 export interface Config {
 	compilerOptions?: CompileOptions;
 	extensions?: string[];
-	kit?: {
-		adapter?: Adapter;
-		alias?: Record<string, string>;
-		appDir?: string;
-		browser?: {
-			hydrate?: boolean;
-			router?: boolean;
-		};
-		csp?: {
-			mode?: 'hash' | 'nonce' | 'auto';
-			directives?: CspDirectives;
-		};
-		endpointExtensions?: string[];
-		files?: {
-			assets?: string;
-			hooks?: string;
-			lib?: string;
-			params?: string;
-			routes?: string;
-			serviceWorker?: string;
-			template?: string;
-		};
-		floc?: boolean;
-		inlineStyleThreshold?: number;
-		methodOverride?: {
-			parameter?: string;
-			allowed?: string[];
-		};
-		outDir?: string;
-		package?: {
-			dir?: string;
-			emitTypes?: boolean;
-			exports?(filepath: string): boolean;
-			files?(filepath: string): boolean;
-		};
-		paths?: {
-			assets?: string;
-			base?: string;
-		};
-		prerender?: {
-			concurrency?: number;
-			crawl?: boolean;
-			default?: boolean;
-			enabled?: boolean;
-			entries?: Array<'*' | `/${string}`>;
-			onError?: PrerenderOnErrorValue;
-		};
-		routes?: (filepath: string) => boolean;
-		serviceWorker?: {
-			register?: boolean;
-			files?: (filepath: string) => boolean;
-		};
-		trailingSlash?: TrailingSlash;
-		version?: {
-			name?: string;
-			pollInterval?: number;
-		};
-		vite?: import('vite').UserConfig | (() => MaybePromise<import('vite').UserConfig>);
-	};
+	kit?: KitConfig;
 	preprocess?: any;
 }
 
+export interface KitConfig {
+	adapter?: Adapter;
+	alias?: Record<string, string>;
+	appDir?: string;
+	browser?: {
+		hydrate?: boolean;
+		router?: boolean;
+	};
+	csp?: {
+		mode?: 'hash' | 'nonce' | 'auto';
+		directives?: CspDirectives;
+	};
+	endpointExtensions?: string[];
+	files?: {
+		assets?: string;
+		hooks?: string;
+		lib?: string;
+		params?: string;
+		routes?: string;
+		serviceWorker?: string;
+		template?: string;
+	};
+	floc?: boolean;
+	inlineStyleThreshold?: number;
+	methodOverride?: {
+		parameter?: string;
+		allowed?: string[];
+	};
+	outDir?: string;
+	package?: {
+		dir?: string;
+		emitTypes?: boolean;
+		exports?(filepath: string): boolean;
+		files?(filepath: string): boolean;
+	};
+	paths?: {
+		assets?: string;
+		base?: string;
+	};
+	prerender?: {
+		concurrency?: number;
+		crawl?: boolean;
+		default?: boolean;
+		enabled?: boolean;
+		entries?: Array<'*' | `/${string}`>;
+		onError?: PrerenderOnErrorValue;
+	};
+	routes?: (filepath: string) => boolean;
+	serviceWorker?: {
+		register?: boolean;
+		files?: (filepath: string) => boolean;
+	};
+	trailingSlash?: TrailingSlash;
+	version?: {
+		name?: string;
+		pollInterval?: number;
+	};
+	vite?: import('vite').UserConfig | (() => MaybePromise<import('vite').UserConfig>);
+}
+
 export interface ExternalFetch {
 	(req: Request): Promise<Response>;
 }
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 7fa6a0c92..794d14524 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -5,6 +5,7 @@ import {
 	GetSession,
 	Handle,
 	HandleError,
+	KitConfig,
 	Load,
 	RequestEvent,
 	RequestHandler,
@@ -319,5 +320,7 @@ export type StrictBody = string | Uint8Array;
 
 export type ValidatedConfig = RecursiveRequired<Config>;
 
+export type ValidatedKitConfig = RecursiveRequired<KitConfig>;
+
 export * from './index';
 export * from './private';
diff --git a/sites/kit.svelte.dev/src/lib/icons/search.svg b/sites/kit.svelte.dev/src/lib/icons/search.svg
new file mode 100644
index 000000000..b588f60bf
--- /dev/null
+++ b/sites/kit.svelte.dev/src/lib/icons/search.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+	<path
+		fill="#ccc"
+		d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"
+	/>
+</svg>
\ No newline at end of file
diff --git a/sites/kit.svelte.dev/src/lib/search/Search.svelte b/sites/kit.svelte.dev/src/lib/search/Search.svelte
index 311690144..e5de0cb55 100644
--- a/sites/kit.svelte.dev/src/lib/search/Search.svelte
+++ b/sites/kit.svelte.dev/src/lib/search/Search.svelte
@@ -1,10 +1,14 @@
 <script>
+	import { browser } from '$app/env';
 	import { searching, query } from './stores.js';
+
+	export let q = '';
 </script>
 
-<div class="search-container">
+<form class="search-container" action="/search">
 	<input
 		id="search"
+		value={q}
 		on:input={(e) => {
 			$searching = true;
 			$query = e.target.value;
@@ -13,30 +17,19 @@
 		on:mousedown|preventDefault={() => ($searching = true)}
 		on:touchstart|preventDefault={() => ($searching = true)}
 		type="search"
+		name="q"
+		placeholder="Search"
+		spellcheck="false"
 	/>
 
-	<label for="#search">
-		<svg viewBox="0 0 24 24" aria-hidden="true">
-			<path
-				fill="currentColor"
-				d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"
-			/>
-		</svg>
-
-		<span>Search</span> <kbd>{navigator.platform === 'MacIntel' ? '⌘' : 'Ctrl'}</kbd> <kbd>K</kbd>
-	</label>
-</div>
+	{#if browser}
+		<label for="#search">
+			<kbd>{navigator.platform === 'MacIntel' ? '⌘' : 'Ctrl'}</kbd> <kbd>K</kbd>
+		</label>
+	{/if}
+</form>
 
 <style>
-	.search-container {
-		position: relative;
-		display: flex;
-		align-items: center;
-		animation: fade-in 0.2s;
-		width: 100%;
-		height: 100%;
-	}
-
 	@keyframes fade-in {
 		from {
 			opacity: 0;
@@ -46,51 +39,51 @@
 		}
 	}
 
+	.search-container {
+		position: relative;
+		display: flex;
+		align-items: center;
+		width: 100%;
+		height: 100%;
+	}
+
 	input {
-		padding: 0.5em 0.5em 0.4em 0.5em;
+		padding: 0.5em 0.5em 0.4em 2em;
 		border: 1px solid #ccc;
 		font-family: inherit;
-		font-size: 1.6rem;
-	}
-
-	input[type='search'] {
-		text-align: center;
+		font-size: 1.4rem;
+		/* text-align: center; */
 		appearance: none;
 		-webkit-appearance: none;
-	}
-
-	.search-container svg {
-		position: absolute;
-		display: inline;
-		width: 1.6rem;
-		height: 1.6rem;
-		left: 1rem;
-		top: calc(50% - 0.8rem);
-		color: #ccc;
-	}
-
-	.search-container input {
 		width: 100%;
 		height: 3.2rem;
 		border-radius: var(--border-r);
+		background: no-repeat 1rem 50% / 1em 1em url(../icons/search.svg);
 	}
 
-	.search-container input:focus + label {
+	input:focus + label {
 		display: none;
 	}
 
-	.search-container label {
+	input::placeholder {
+		font-size: 1.2rem;
+		text-transform: uppercase;
+	}
+
+	label {
 		color: #666;
 		position: absolute;
 		top: calc(50% - 0.9rem);
+		right: 0;
 		width: 100%;
-		text-align: center;
+		text-align: right;
 		pointer-events: none;
 		font-size: 1.2rem;
 		text-transform: uppercase;
+		animation: fade-in 0.2s;
 	}
 
-	.search-container kbd {
+	kbd {
 		display: none;
 		background: #eee;
 		border: 1px solid #ddd;
@@ -106,36 +99,31 @@
 			width: 11rem;
 		}
 
-		.search-container label {
-			text-align: right;
+		label {
 			padding: 0 1.6rem 0 0;
 		}
 
-		.search-container span {
-			display: none;
+		input {
+			border-radius: 1.6rem;
 		}
 
-		.search-container input {
-			border-radius: 1.6rem;
+		input::placeholder {
+			opacity: 0;
 		}
 
 		/* we're using media query as an imperfect proxy for mobile/desktop */
-		.search-container kbd {
+		kbd {
 			display: inline;
 		}
 	}
 
 	@media (min-width: 960px) {
 		.search-container {
-			width: 100%;
+			width: 19rem;
 		}
 
-		.search-container label {
-			text-align: center;
-		}
-
-		.search-container span {
-			display: inline;
+		input::placeholder {
+			opacity: 1;
 		}
 	}
 </style>
diff --git a/sites/kit.svelte.dev/src/lib/search/SearchBox.svelte b/sites/kit.svelte.dev/src/lib/search/SearchBox.svelte
index 80b9a3b46..739bafcff 100644
--- a/sites/kit.svelte.dev/src/lib/search/SearchBox.svelte
+++ b/sites/kit.svelte.dev/src/lib/search/SearchBox.svelte
@@ -1,50 +1,49 @@
 <script>
 	import { onMount } from 'svelte';
 	import { Icon } from '@sveltejs/site-kit';
-	import flexsearch from 'flexsearch';
 	import { afterNavigate } from '$app/navigation';
 	import { searching, query, recent } from './stores.js';
 	import { focusable_children, trap } from '../actions/focus.js';
+	import SearchResults from './SearchResults.svelte';
+	import SearchWorker from '$lib/workers/search.js?worker';
 
 	let modal;
 
-	let results = [];
+	let search = null;
+	let recent_searches = [];
 
-	let indexes;
-	let lookup;
+	let worker;
+	let ready = false;
+
+	let uid = 1;
+	const pending = new Set();
+	let has_pending = false;
 
 	onMount(async () => {
-		const response = await fetch('/content.json');
-		const { blocks } = await response.json();
-
-		// we have multiple indexes, so we can rank sections (migration guide comes last)
-		const max_rank = Math.max(...blocks.map((block) => block.rank ?? 0));
-		indexes = Array.from(
-			{ length: max_rank + 1 },
-			() => new flexsearch.Index({ tokenize: 'forward' })
-		);
-
-		lookup = new Map();
-
-		let time = Date.now();
-		for (const block of blocks) {
-			const title = block.breadcrumbs.pop();
-			lookup.set(block.href, {
-				title,
-				href: block.href,
-				breadcrumbs: block.breadcrumbs,
-				content: block.content
-			});
-			indexes[block.rank ?? 0].add(block.href, `${title} ${block.content}`);
-
-			// poor man's way of preventing blocking
-			if (Date.now() - time > 25) {
-				await new Promise((f) => setTimeout(f, 0));
-				time = Date.now();
+		worker = new SearchWorker();
+
+		worker.addEventListener('message', (event) => {
+			const { type, payload } = event.data;
+
+			if (type === 'ready') {
+				ready = true;
+			}
+
+			if (type === 'results') {
+				search = payload;
 			}
-		}
 
-		update();
+			if (type === 'recents') {
+				recent_searches = payload;
+			}
+		});
+
+		worker.postMessage({
+			type: 'init',
+			payload: {
+				origin: location.origin
+			}
+		});
 	});
 
 	afterNavigate(() => {
@@ -64,50 +63,32 @@
 			document.body.removeAttribute('tabindex');
 			window.scrollTo(0, scroll);
 		}
-	}
 
-	function update() {
-		results = (indexes ? indexes.map((index) => index.search($query)).flat() : []).map((href) =>
-			lookup.get(href)
-		);
+		search = null;
 	}
 
-	function escape(text) {
-		return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
+	/** @param {string} href */
+	function navigate(href) {
+		$recent = [href, ...$recent.filter((x) => x !== href)];
+		close();
 	}
 
-	function excerpt(content, query) {
-		const index = content.toLowerCase().indexOf(query.toLowerCase());
-		if (index === -1) {
-			return content.slice(0, 100);
-		}
-
-		const prefix = index > 20 ? `…${content.slice(index - 15, index)}` : content.slice(0, index);
-		const suffix = content.slice(
-			index + query.length,
-			index + query.length + (80 - (prefix.length + query.length))
-		);
+	$: if (ready) {
+		const id = uid++;
+		pending.add(id);
+		has_pending = true;
 
-		return (
-			escape(prefix) +
-			`<mark>${escape(content.slice(index, index + query.length))}</mark>` +
-			escape(suffix)
-		);
+		worker.postMessage({ type: 'query', id, payload: $query });
 	}
 
-	/** @param {string} href */
-	function navigate(href) {
-		$recent = [href, ...$recent.filter((x) => x !== href)];
-		close();
+	$: if (ready) {
+		worker.postMessage({ type: 'recents', payload: $recent });
 	}
 
 	$: if ($searching) {
-		update();
 		document.body.style.top = `-${window.scrollY}px`;
 		document.body.style.position = 'fixed';
 	}
-
-	$: recent_searches = lookup ? $recent.map((href) => lookup.get(href)).filter(Boolean) : [];
 </script>
 
 <svelte:window
@@ -129,7 +110,7 @@
 	}}
 />
 
-{#if $searching && indexes}
+{#if $searching && ready}
 	<div class="modal-background" on:click={close} />
 
 	<div
@@ -158,18 +139,16 @@
 				autofocus
 				on:keydown={(e) => {
 					if (e.key === 'Enter') {
-						if (results.length > 0) {
-							modal.querySelector('a').click();
-						}
+						modal.querySelector('a[data-has-node]')?.click();
 					}
 				}}
 				on:input={(e) => {
 					$query = e.target.value;
-					update();
 				}}
 				value={$query}
 				placeholder="Search"
 				aria-describedby="search-description"
+				spellcheck="false"
 			/>
 
 			<button aria-label="Close" on:click={close}>
@@ -179,48 +158,43 @@
 			<span id="search-description" class="visually-hidden">Results will update as you type</span>
 
 			<div class="results">
-				{#if $query}
-					{#if results.length > 0}
-						<ul>
-							{#each results as result, i}
-								<!-- svelte-ignore a11y-mouse-events-have-key-events -->
-								<li>
-									<a on:click={() => navigate(result.href)} href={result.href}>
-										<small>{result.breadcrumbs.join('/')}</small>
-										<strong>{@html excerpt(result.title, $query)}</strong>
-										<span>{@html excerpt(result.content, $query)}</span>
-									</a>
-								</li>
-							{/each}
-						</ul>
-					{:else}
-						<p class="info">No results</p>
-					{/if}
+				{#if search?.query}
+					<div class="results-container" on:click={() => ($searching = false)}>
+						<SearchResults
+							results={search.results}
+							query={search.query}
+							on:select={(e) => {
+								navigate(e.detail.href);
+							}}
+						/>
+					</div>
 				{:else}
 					<h2 class="info">{recent_searches.length ? 'Recent searches' : 'No recent searches'}</h2>
 					{#if recent_searches.length}
-						<ul>
-							{#each recent_searches as search, i}
-								<!-- svelte-ignore a11y-mouse-events-have-key-events -->
-								<li class="recent">
-									<a on:click={() => navigate(search.href)} href={search.href}>
-										<small>{search.breadcrumbs.join('/')}</small>
-										<strong>{search.title}</strong>
-									</a>
-
-									<button
-										aria-label="Delete"
-										on:click={(e) => {
-											$recent = $recent.filter((href) => href !== search.href);
-											e.stopPropagation();
-											e.preventDefault();
-										}}
-									>
-										<Icon name="delete" />
-									</button>
-								</li>
-							{/each}
-						</ul>
+						<div class="results-container">
+							<ul>
+								{#each recent_searches as search, i}
+									<!-- svelte-ignore a11y-mouse-events-have-key-events -->
+									<li class="recent">
+										<a on:click={() => navigate(search.href)} href={search.href}>
+											<small>{search.breadcrumbs.join('/')}</small>
+											<strong>{search.title}</strong>
+										</a>
+
+										<button
+											aria-label="Delete"
+											on:click={(e) => {
+												$recent = $recent.filter((href) => href !== search.href);
+												e.stopPropagation();
+												e.preventDefault();
+											}}
+										>
+											<Icon name="delete" />
+										</button>
+									</li>
+								{/each}
+							</ul>
+						</div>
 					{/if}
 				{/if}
 			</div>
@@ -229,7 +203,7 @@
 {/if}
 
 <div aria-live="assertive">
-	{#if $searching && $query && results.length === 0}
+	{#if $searching && search?.results.length === 0}
 		<p>No results</p>
 	{/if}
 </div>
@@ -325,19 +299,12 @@
 		overscroll-behavior-y: none;
 	}
 
-	ul {
-		margin: calc(-1 * var(--border-r)) 0 0 0;
+	.results-container {
 		background: white;
 		border-radius: 0 0 var(--border-r) var(--border-r);
 		pointer-events: all;
 	}
 
-	li {
-		position: relative;
-		list-style: none;
-		margin: 0;
-	}
-
 	.info {
 		padding: 1rem;
 		font-size: 1.2rem;
@@ -366,8 +333,7 @@
 	}
 
 	a small,
-	a strong,
-	a span {
+	a strong {
 		display: block;
 		white-space: nowrap;
 		overflow: hidden;
@@ -388,18 +354,7 @@
 		margin: 0.4rem 0;
 	}
 
-	a span {
-		font-size: 1.2rem;
-		color: #999;
-	}
-
-	a span :global(mark) {
-		background: none;
-		color: #111;
-	}
-
-	a:focus small,
-	a:focus span {
+	a:focus small {
 		color: rgba(255, 255, 255, 0.6);
 	}
 
@@ -407,10 +362,6 @@
 		color: white;
 	}
 
-	a:focus span :global(mark) {
-		color: white;
-	}
-
 	a strong :global(mark) {
 		background: var(--second);
 		color: white;
@@ -418,6 +369,10 @@
 		border-radius: 1px;
 	}
 
+	li {
+		position: relative;
+	}
+
 	button[aria-label='Delete'] {
 		position: absolute;
 		top: 0;
diff --git a/sites/kit.svelte.dev/src/lib/search/SearchResultList.svelte b/sites/kit.svelte.dev/src/lib/search/SearchResultList.svelte
new file mode 100644
index 000000000..5fd381349
--- /dev/null
+++ b/sites/kit.svelte.dev/src/lib/search/SearchResultList.svelte
@@ -0,0 +1,155 @@
+<script>
+	import { createEventDispatcher } from 'svelte';
+
+	/** @type {import('./types').Tree[]} */
+	export let results;
+
+	/** @type {string} */
+	export let query;
+
+	const dispatch = createEventDispatcher();
+
+	function escape(text) {
+		return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
+	}
+
+	function excerpt(content, query) {
+		const index = content.toLowerCase().indexOf(query.toLowerCase());
+		if (index === -1) {
+			return content.slice(0, 100);
+		}
+
+		const prefix = index > 20 ? `…${content.slice(index - 15, index)}` : content.slice(0, index);
+		const suffix = content.slice(
+			index + query.length,
+			index + query.length + (80 - (prefix.length + query.length))
+		);
+
+		return (
+			escape(prefix) +
+			`<mark>${escape(content.slice(index, index + query.length))}</mark>` +
+			escape(suffix)
+		);
+	}
+</script>
+
+<ul>
+	{#each results as result, i}
+		<li>
+			<a
+				href={result.href}
+				on:click={() => dispatch('select', { href: result.href })}
+				data-has-node={result.node ? true : undefined}
+			>
+				<strong>{@html excerpt(result.breadcrumbs.at(-1), query)}</strong>
+
+				{#if result.node?.content}
+					<span>{@html excerpt(result.node.content, query)}</span>
+				{/if}
+			</a>
+
+			{#if result.children.length > 0}
+				<svelte:self results={result.children} {query} on:select />
+			{/if}
+		</li>
+	{/each}
+</ul>
+
+<style>
+	ul {
+		position: relative;
+		margin: 0;
+	}
+
+	ul :global(ul) {
+		margin-left: 0.8em !important;
+		padding-left: 0em;
+		border-left: 1px solid #eee;
+	}
+
+	li {
+		list-style: none;
+		margin-bottom: 1em;
+	}
+
+	li:last-child {
+		margin-bottom: 0;
+	}
+
+	ul ul li {
+		margin: 0;
+	}
+
+	a {
+		display: block;
+		text-decoration: none;
+		line-height: 1;
+		padding: 1rem;
+	}
+
+	a:hover {
+		background: rgba(0, 0, 0, 0.05);
+	}
+
+	a:focus {
+		background: var(--second);
+		color: white;
+		outline: none;
+	}
+
+	a strong,
+	a span {
+		display: block;
+		white-space: nowrap;
+		line-height: 1;
+	}
+
+	a strong {
+		font-size: 1.6rem;
+		color: var(--text);
+	}
+
+	a span {
+		font-size: 1.2rem;
+		color: #999;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		margin: 0.4rem 0 0 0;
+	}
+
+	a :global(mark) {
+		--highlight-color: rgba(255, 255, 0, 0.2);
+	}
+
+	a span :global(mark) {
+		background: none;
+		color: #111;
+		background: var(--highlight-color);
+		outline: 2px solid var(--highlight-color);
+		border-top: 2px solid var(--highlight-color);
+		mix-blend-mode: darken;
+	}
+
+	a:focus span {
+		color: rgba(255, 255, 255, 0.6);
+	}
+
+	a:focus strong {
+		color: white;
+	}
+
+	a:focus span :global(mark),
+	a:focus strong :global(mark) {
+		--highlight-color: hsl(240, 8%, 54%);
+		mix-blend-mode: lighten;
+		color: white;
+	}
+
+	a strong :global(mark) {
+		color: black;
+		background: var(--highlight-color);
+		outline: 2px solid var(--highlight-color);
+		/* border-top: 2px solid var(--highlight-color); */
+		border-radius: 1px;
+	}
+</style>
diff --git a/sites/kit.svelte.dev/src/lib/search/SearchResults.svelte b/sites/kit.svelte.dev/src/lib/search/SearchResults.svelte
new file mode 100644
index 000000000..e05901938
--- /dev/null
+++ b/sites/kit.svelte.dev/src/lib/search/SearchResults.svelte
@@ -0,0 +1,27 @@
+<script>
+	import SearchResultList from './SearchResultList.svelte';
+
+	/** @type {import('./types').Tree[]} */
+	export let results;
+
+	/** @type {string} */
+	export let query;
+</script>
+
+{#if results.length > 0}
+	<SearchResultList {results} {query} on:select />
+{:else}
+	<p class="info">No results</p>
+{/if}
+
+<style>
+	.info {
+		padding: 1rem;
+		font-size: 1.2rem;
+		font-weight: normal;
+		text-transform: uppercase;
+		background-color: white;
+		border-radius: 0 0 var(--border-r) var(--border-r);
+		pointer-events: all;
+	}
+</style>
diff --git a/sites/kit.svelte.dev/src/lib/search/search.js b/sites/kit.svelte.dev/src/lib/search/search.js
new file mode 100644
index 000000000..7f08c1b7f
--- /dev/null
+++ b/sites/kit.svelte.dev/src/lib/search/search.js
@@ -0,0 +1,98 @@
+import flexsearch from 'flexsearch';
+
+export let inited = false;
+
+/** @type {import('flexsearch').Index[]} */
+let indexes;
+
+/** @type {Map<string, import('./types').Block>} */
+const map = new Map();
+
+/** @type {Map<string, string>} */
+const hrefs = new Map();
+
+export function init(blocks) {
+	if (inited) return;
+
+	// we have multiple indexes, so we can rank sections (migration guide comes last)
+	const max_rank = Math.max(...blocks.map((block) => block.rank ?? 0));
+
+	indexes = Array.from(
+		{ length: max_rank + 1 },
+		() => new flexsearch.Index({ tokenize: 'forward' })
+	);
+
+	for (const block of blocks) {
+		const title = block.breadcrumbs.at(-1);
+		map.set(block.href, {
+			title,
+			href: block.href,
+			breadcrumbs: block.breadcrumbs,
+			content: block.content
+		});
+		indexes[block.rank ?? 0].add(block.href, `${title} ${block.content}`);
+
+		hrefs.set(block.breadcrumbs.join('::'), block.href);
+	}
+
+	inited = true;
+}
+
+/**
+ * @param {string} query
+ * @returns {import('./types').Block[]}
+ */
+export function search(query) {
+	const regex = new RegExp(`\\b${query}`, 'i');
+
+	const blocks = indexes
+		.map((index) => index.search(query))
+		.flat()
+		.map(lookup)
+		.map((block, rank) => ({ block, rank }))
+		.sort((a, b) => {
+			const a_title_matches = regex.test(a.block.title);
+			const b_title_matches = regex.test(b.block.title);
+
+			// massage the order a bit, so that title matches
+			// are given higher priority
+			if (a_title_matches !== b_title_matches) {
+				return a_title_matches ? -1 : 1;
+			}
+
+			return a.block.breadcrumbs.length - b.block.breadcrumbs.length || a.rank - b.rank;
+		})
+		.map(({ block }) => block);
+
+	const results = tree([], blocks).children;
+
+	return results;
+}
+
+/** @param {string} href */
+export function lookup(href) {
+	return map.get(href);
+}
+
+function tree(breadcrumbs, blocks) {
+	const depth = breadcrumbs.length;
+
+	const node = blocks.find((block) => {
+		if (block.breadcrumbs.length !== depth) return false;
+		return breadcrumbs.every((part, i) => block.breadcrumbs[i] === part);
+	});
+
+	const descendants = blocks.filter((block) => {
+		if (block.breadcrumbs.length <= depth) return false;
+		return breadcrumbs.every((part, i) => block.breadcrumbs[i] === part);
+	});
+
+	const child_parts = Array.from(new Set(descendants.map((block) => block.breadcrumbs[depth])));
+
+	return {
+		breadcrumbs,
+		href: hrefs.get(breadcrumbs.join('::')),
+		node,
+		children: child_parts.map((part) => tree([...breadcrumbs, part], descendants))
+	};
+}
diff --git a/sites/kit.svelte.dev/src/lib/search/types.d.ts b/sites/kit.svelte.dev/src/lib/search/types.d.ts
new file mode 100644
index 000000000..fbbbdba23
--- /dev/null
+++ b/sites/kit.svelte.dev/src/lib/search/types.d.ts
@@ -0,0 +1,13 @@
+export interface Block {
+	breadcrumbs: string[];
+	href: string;
+	content: string;
+	rank: number;
+}
+
+export interface Tree {
+	breadcrumbs: string[];
+	href: string;
+	node: Block;
+	children: Tree[];
+}
diff --git a/sites/kit.svelte.dev/src/lib/workers/search.js b/sites/kit.svelte.dev/src/lib/workers/search.js
new file mode 100644
index 000000000..2cb522774
--- /dev/null
+++ b/sites/kit.svelte.dev/src/lib/workers/search.js
@@ -0,0 +1,26 @@
+import { init, search, lookup } from '../search/search.js';
+
+addEventListener('message', async (event) => {
+	const { type, payload } = event.data;
+
+	if (type === 'init') {
+		const res = await fetch(`${payload.origin}/content.json`);
+		const { blocks } = await res.json();
+		init(blocks);
+
+		postMessage({ type: 'ready' });
+	}
+
+	if (type === 'query') {
+		const query = payload;
+		const results = search(query);
+
+		postMessage({ type: 'results', payload: { results, query } });
+	}
+
+	if (type === 'recents') {
+		const results = payload.map(lookup).filter(Boolean);
+
+		postMessage({ type: 'recents', payload: results });
+	}
+});
diff --git a/sites/kit.svelte.dev/src/routes/__layout.svelte b/sites/kit.svelte.dev/src/routes/__layout.svelte
index 428e6519d..5c7871032 100644
--- a/sites/kit.svelte.dev/src/routes/__layout.svelte
+++ b/sites/kit.svelte.dev/src/routes/__layout.svelte
@@ -16,10 +16,10 @@
 <SkipLink href="#main" />
 <Nav {page} logo="/stopwar.svg">
 	<svelte:fragment slot="nav-center">
-		{#if browser}
+		{#if $page.url.pathname !== '/search'}
 			<!-- the <Nav> component renders this content inside a <ul>, so
-				 we need to wrap it in an <li>. TODO if we adopt this design
-				 on other sites, change <Nav> so we don't need to do this -->
+				we need to wrap it in an <li>. TODO if we adopt this design
+				on other sites, change <Nav> so we don't need to do this -->
 			<li><Search /></li>
 		{/if}
 	</svelte:fragment>
@@ -116,13 +116,9 @@
 		}
 	}
 
-	/* this is an unfortunate hack, but necessary to temporarily avoid
-	   breaking changes to site-kit */
-	:global(ul.external) {
-		width: 40rem !important;
-	}
-
 	@media (min-width: 960px) {
+		/* this is an unfortunate hack, but necessary to temporarily avoid
+		breaking changes to site-kit */
 		:global(ul.external) {
 			width: 30rem !important;
 		}
@@ -132,6 +128,11 @@
 		font-size: 1.6rem !important;
 	}
 
+	li {
+		display: flex;
+		align-items: center;
+	}
+
 	/** Ukraine banner */
 	:root {
 		--ukr-footer-height: 48px;
diff --git a/sites/kit.svelte.dev/src/routes/content.json.js b/sites/kit.svelte.dev/src/routes/content.json.js
index c94e73eb4..a46158765 100644
--- a/sites/kit.svelte.dev/src/routes/content.json.js
+++ b/sites/kit.svelte.dev/src/routes/content.json.js
@@ -18,6 +18,7 @@ const categories = [
 ];
 
 export function get() {
+	/** @type {import('$lib/search/types').Block[]} */
 	const blocks = [];
 
 	for (const category of categories) {
diff --git a/sites/kit.svelte.dev/src/routes/search/index.js b/sites/kit.svelte.dev/src/routes/search/index.js
new file mode 100644
index 000000000..9f2ca7e74
--- /dev/null
+++ b/sites/kit.svelte.dev/src/routes/search/index.js
@@ -0,0 +1,22 @@
+import { init, inited, search } from '$lib/search/search';
+
+/** @type {import('./__types/index').RequestHandler} */
+export async function get({ url }) {
+	if (!inited) {
+		// TODO this feels a bit hacky, not sure if there's a better approach
+		const res = await fetch(`${url.origin}/content.json`);
+		const { blocks } = await res.json();
+		init(blocks);
+	}
+
+	const query = url.searchParams.get('q');
+
+	const results = search(query);
+
+	return {
+		body: {
+			query,
+			results
+		}
+	};
+}
diff --git a/sites/kit.svelte.dev/src/routes/search/index.svelte b/sites/kit.svelte.dev/src/routes/search/index.svelte
new file mode 100644
index 000000000..92505a780
--- /dev/null
+++ b/sites/kit.svelte.dev/src/routes/search/index.svelte
@@ -0,0 +1,54 @@
+<script context="module">
+	export const prerender = false;
+</script>
+
+<script>
+	import SearchResults from '$lib/search/SearchResults.svelte';
+
+	/** @type {import('$lib/search/types').Block[]} */
+	export let results;
+
+	/** @type {string} */
+	export let query;
+</script>
+
+<svelte:head>
+	<title>Search • SvelteKit</title>
+</svelte:head>
+
+<main>
+	<form>
+		<input name="q" value={query} placeholder="Search" spellcheck="false" />
+	</form>
+
+	<SearchResults {results} {query} />
+</main>
+
+<style>
+	main {
+		max-width: 48rem;
+		margin: 0 auto;
+		padding: 8rem 1rem;
+	}
+
+	form {
+		--size: 4rem;
+		position: relative;
+		width: 100%;
+		height: var(--size);
+		margin: 0 0 2em 0;
+	}
+
+	input {
+		display: block;
+		width: 100%;
+		height: 100%;
+		font-family: inherit;
+		font-size: inherit;
+		border: 1px solid #ccc;
+		border-radius: var(--border-r);
+		padding-left: var(--size);
+		border-radius: var(--size);
+		background: no-repeat 1rem 50% / 2rem 2rem url($lib/icons/search.svg);
+	}
+</style>


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/endpoint-output/simple.js b/packages/kit/test/apps/basics/src/routes/endpoint-output/simple.js
new file mode 100644
index 000000000..183ff6652
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/endpoint-output/simple.js
@@ -0,0 +1,7 @@
+export function get() {
+	return {
+		body: {
+			answer: 42
+		}
+	};
+}
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index d5c7de937..cb159068c 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -760,6 +760,17 @@ test.describe.parallel('Endpoints', () => {
 			'name=SvelteKit; path=/; HttpOnly'
 		]);
 	});
+
+	test('Standalone endpoint is not accessible via /__data.json suffix', async ({ request }) => {
+		const r1 = await request.get('/endpoint-output/simple', {
+			headers: { accept: 'application/json' }
+		});
+
+		expect(await r1.json()).toEqual({ answer: 42 });
+
+		const r2 = await request.get('/endpoint-output/simple/__data.json');
+		expect(r2.status()).toBe(404);
+	});
 });
 
 test.describe.parallel('Encoded paths', () => {
diff --git a/packages/kit/test/apps/options/source/pages/page-endpoint/index.js b/packages/kit/test/apps/options/source/pages/page-endpoint/index.js
new file mode 100644
index 000000000..4cca8c24c
--- /dev/null
+++ b/packages/kit/test/apps/options/source/pages/page-endpoint/index.js
@@ -0,0 +1,7 @@
+export function get() {
+	return {
+		body: {
+			data: 'hi'
+		}
+	};
+}
diff --git a/packages/kit/test/apps/options/source/pages/page-endpoint/index.svelte b/packages/kit/test/apps/options/source/pages/page-endpoint/index.svelte
new file mode 100644
index 000000000..173c865c6
--- /dev/null
+++ b/packages/kit/test/apps/options/source/pages/page-endpoint/index.svelte
@@ -0,0 +1,6 @@
+<script>
+	/** @type {string} */
+	export let data;
+</script>
+
+<h2>{data}</h2>
diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js
index d3d31ba63..ffe5d7df8 100644
--- a/packages/kit/test/apps/options/test/test.js
+++ b/packages/kit/test/apps/options/test/test.js
@@ -161,6 +161,12 @@ test.describe.parallel('trailingSlash', () => {
 		expect(r2.url()).toBe(`${baseURL}/path-base/endpoint`);
 		expect(await r2.text()).toBe('hi');
 	});
+
+	test('can fetch data from page-endpoint', async ({ request, baseURL }) => {
+		const r = await request.get('/path-base/page-endpoint/__data.json');
+		expect(r.url()).toBe(`${baseURL}/path-base/page-endpoint/__data.json`);
+		expect(await r.json()).toEqual({ data: 'hi' });
+	});
 });
 
 test.describe.parallel('serviceWorker', () => {