Repository: sveltejs/kit
Pull request #9513 (2023-03-25 13:55:53)

fix: balance parentheses in error about wrong content type for action
When you send the wrong content type to an action, SvelteKit throws a helpful error, but it's missing the parentheses at the end:

```
Actions expect form-encoded data (received application/json;
                                                            ^ Whoops
```

This PR adds it. That's all! Thanks!

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. Changesets that add features should be `minor` and those that fix bugs should be `patch`. Please prefix changeset messages with `feat:`, `fix:`, or `chore:`.


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: c6bcd238dba7fc467f6afb6cf3a448ee9d6f5e5e

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name          | Type  |
| ------------- | ----- |
| @sveltejs/kit | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/danieldiekmeier/kit/new/patch-2?filename=.changeset/four-schools-drum.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A---%0A%0ABalance%20parantheses%20in%20error%20about%20wrong%20content%20type%20for%20action%0A)



Patch:
diff --git a/.changeset/sharp-trainers-search.md b/.changeset/sharp-trainers-search.md
new file mode 100644
index 000000000..0d143f8a4
--- /dev/null
+++ b/.changeset/sharp-trainers-search.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+fix: make `error.message` enumerable when sending `ssrLoadModule` error to client
diff --git a/.changeset/sharp-wolves-march.md b/.changeset/sharp-wolves-march.md
new file mode 100644
index 000000000..64aa30be2
--- /dev/null
+++ b/.changeset/sharp-wolves-march.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+fix: pass `publicDir` Vite config in SSR
diff --git a/.changeset/stupid-trains-push.md b/.changeset/stupid-trains-push.md
new file mode 100644
index 000000000..58f77cd1c
--- /dev/null
+++ b/.changeset/stupid-trains-push.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: expose stronger typed `SubmitFunction` through `./$types`
diff --git a/.changeset/unlucky-garlics-yell.md b/.changeset/unlucky-garlics-yell.md
new file mode 100644
index 000000000..6bbb023e9
--- /dev/null
+++ b/.changeset/unlucky-garlics-yell.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+fix: balance parentheses in error about wrong content type for action
diff --git a/package.json b/package.json
index 38b539a61..83923a0ee 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
 		"tiny-glob": "^0.2.9",
 		"typescript": "^4.9.4"
 	},
-	"packageManager": "pnpm@7.30.1",
+	"packageManager": "pnpm@7.30.5",
 	"engines": {
 		"pnpm": "^7.0.0"
 	},
diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js
index 0ae180492..c1e0c827c 100644
--- a/packages/kit/src/core/sync/write_types/index.js
+++ b/packages/kit/src/core/sync/write_types/index.js
@@ -390,16 +390,23 @@ function process_node(node, outdir, is_page, proxies, all_pages_have_load = true
 
 		if (is_page) {
 			let type = 'unknown';
-			if (proxy) {
-				if (proxy.exports.includes('actions')) {
-					// If the file wasn't tweaked, we can use the return type of the original file.
-					// The advantage is that type updates are reflected without saving.
-					const from = proxy.modified
-						? `./proxy${replace_ext_with_js(basename)}`
-						: path_to_original(outdir, node.server);
-
-					type = `Expand<Kit.AwaitedActions<typeof import('${from}').actions>> | null`;
-				}
+			if (proxy && proxy.exports.includes('actions')) {
+				// If the file wasn't tweaked, we can use the return type of the original file.
+				// The advantage is that type updates are reflected without saving.
+				const from = proxy.modified
+					? `./proxy${replace_ext_with_js(basename)}`
+					: path_to_original(outdir, node.server);
+
+				exports.push(
+					`type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;`,
+					`type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];`,
+					`type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X>	? X extends void ? never : X : never;`,
+					`type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];`,
+					`type ActionsExport = typeof import('${from}').actions`,
+					`export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>`
+				);
+
+				type = `Expand<Kit.AwaitedActions<ActionsExport>> | null`;
 			}
 			exports.push(`export type ActionData = ${type};`);
 		}
diff --git a/packages/kit/src/core/sync/write_types/index.spec.js b/packages/kit/src/core/sync/write_types/index.spec.js
index 5abb42c66..1971a959f 100644
--- a/packages/kit/src/core/sync/write_types/index.spec.js
+++ b/packages/kit/src/core/sync/write_types/index.spec.js
@@ -33,6 +33,7 @@ test('Creates correct $types', async () => {
 	// To safe us from creating a real SvelteKit project for each of the tests,
 	// we first run the type generation directly for each test case, and then
 	// call `tsc` to check that the generated types are valid.
+	await run_test('actions');
 	await run_test('simple-page-shared-only');
 	await run_test('simple-page-server-only');
 	await run_test('simple-page-server-and-shared');
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index 1addb038e..d279e488c 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -58,7 +58,16 @@ export async function dev(vite, vite_config, svelte_config) {
 			const msg = buildErrorMessage(err, [colors.red(`Internal server error: ${err.message}`)]);
 
 			vite.config.logger.error(msg, { error: err });
-			vite.ws.send({ type: 'error', err: err });
+			vite.ws.send({
+				type: 'error',
+				err: {
+					...err,
+					// these properties are non-enumerable and will
+					// not be serialized unless we explicitly include them
+					message: err.message,
+					stack: err.stack
+				}
+			});
 
 			throw err;
 		}
diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js
index 028d873e3..c2a339693 100644
--- a/packages/kit/src/exports/vite/index.js
+++ b/packages/kit/src/exports/vite/index.js
@@ -564,11 +564,12 @@ function kit({ svelte_config }) {
 							preserveEntrySignatures: 'strict'
 						},
 						ssrEmitAssets: true,
+						copyPublicDir: !ssr,
 						target: ssr ? 'node16.14' : undefined,
 						// don't use the default name to avoid collisions with 'static/manifest.json'
 						manifest: 'vite-manifest.json'
 					},
-					publicDir: ssr ? false : kit.files.assets,
+					publicDir: kit.files.assets,
 					worker: {
 						rollupOptions: {
 							output: {
diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js
index ed8b3273f..33dd11170 100644
--- a/packages/kit/src/runtime/server/page/actions.js
+++ b/packages/kit/src/runtime/server/page/actions.js
@@ -214,7 +214,7 @@ async function call_action(event, actions) {
 
 	if (!is_form_content_type(event.request)) {
 		throw new Error(
-			`Actions expect form-encoded data (received ${event.request.headers.get('content-type')}`
+			`Actions expect form-encoded data (received ${event.request.headers.get('content-type')})`
 		);
 	}
 
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index c2019c4e2..19d6c8424 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -1167,10 +1167,10 @@ export type Actions<
  */
 export type ActionResult<
 	Success extends Record<string, unknown> | undefined = Record<string, any>,
-	Invalid extends Record<string, unknown> | undefined = Record<string, any>
+	Failure extends Record<string, unknown> | undefined = Record<string, any>
 > =
 	| { type: 'success'; status: number; data?: Success }
-	| { type: 'failure'; status: number; data?: Invalid }
+	| { type: 'failure'; status: number; data?: Failure }
 	| { type: 'redirect'; status: number; location: string }
 	| { type: 'error'; status?: number; error: any };
 
@@ -1239,7 +1239,7 @@ export function text(body: string, init?: ResponseInit): Response;
  * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599.
  * @param data Data associated with the failure (e.g. validation errors)
  */
-export function fail<T extends Record<string, unknown> | undefined>(
+export function fail<T extends Record<string, unknown> | undefined = undefined>(
 	status: number,
 	data?: T
 ): ActionFailure<T>;
@@ -1257,7 +1257,7 @@ export interface ActionFailure<T extends Record<string, unknown> | undefined = u
 
 export interface SubmitFunction<
 	Success extends Record<string, unknown> | undefined = Record<string, any>,
-	Invalid extends Record<string, unknown> | undefined = Record<string, any>
+	Failure extends Record<string, unknown> | undefined = Record<string, any>
 > {
 	(input: {
 		action: URL;
@@ -1271,7 +1271,7 @@ export interface SubmitFunction<
 		| ((opts: {
 				form: HTMLFormElement;
 				action: URL;
-				result: ActionResult<Success, Invalid>;
+				result: ActionResult<Success, Failure>;
 				/**
 				 * Call this to get the default behavior of a form submission response.
 				 * @param options Set `reset: false` if you don't want the `<form>` values to be reset after a successful submission.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0c7b64565..e730ac26b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -689,7 +689,7 @@ importers:
       '@sveltejs/adapter-vercel': workspace:^
       '@sveltejs/amp': workspace:^
       '@sveltejs/kit': workspace:^
-      '@sveltejs/site-kit': ^3.1.0
+      '@sveltejs/site-kit': ^3.3.6
       '@types/d3-geo': ^3.0.2
       '@types/node': ^16.18.6
       d3-geo: ^3.0.1
@@ -715,7 +715,7 @@ importers:
       '@sveltejs/adapter-vercel': link:../../packages/adapter-vercel
       '@sveltejs/amp': link:../../packages/amp
       '@sveltejs/kit': link:../../packages/kit
-      '@sveltejs/site-kit': 3.1.0
+      '@sveltejs/site-kit': 3.3.6_r4kk7euspt2csvnnykfg5n6pxi
       '@types/d3-geo': 3.0.2
       '@types/node': 16.18.6
       flexsearch: 0.7.31
@@ -1590,8 +1590,14 @@ packages:
       picomatch: 2.3.1
       rollup: 3.7.0
 
-  /@sveltejs/site-kit/3.1.0:
-    resolution: {integrity: sha512-Vv9Efzax+NMRZUfZ9TdUqQYicj2Z3kynLkUUdGPfahjlFEwQ3EGyj1vVCg5jxyqvNUgOsVVUb35nXI5CPsmCfA==}
+  /@sveltejs/site-kit/3.3.6_r4kk7euspt2csvnnykfg5n6pxi:
+    resolution: {integrity: sha512-MUqHseHHFJOT6AcJYTbUqK7hMy1OkBa231tS4TXkmbgQ8EaFVI2unjs9+WY8m3CXASc4p31givOiKVSqtorbbg==}
+    peerDependencies:
+      '@sveltejs/kit': ^1.0.0
+      svelte: ^3.54.0
+    dependencies:
+      '@sveltejs/kit': link:packages\kit
+      svelte: 3.56.0
     dev: true
 
   /@sveltejs/vite-plugin-svelte/2.0.0_svelte@3.56.0+vite@4.2.0:
diff --git a/sites/kit.svelte.dev/package.json b/sites/kit.svelte.dev/package.json
index b6afda661..3ffcb5c26 100644
--- a/sites/kit.svelte.dev/package.json
+++ b/sites/kit.svelte.dev/package.json
@@ -14,7 +14,7 @@
 		"@sveltejs/adapter-vercel": "workspace:^",
 		"@sveltejs/amp": "workspace:^",
 		"@sveltejs/kit": "workspace:^",
-		"@sveltejs/site-kit": "^3.1.0",
+		"@sveltejs/site-kit": "^3.3.6",
 		"@types/d3-geo": "^3.0.2",
 		"@types/node": "^16.18.6",
 		"flexsearch": "^0.7.31",
diff --git a/sites/kit.svelte.dev/src/lib/actions/focus.js b/sites/kit.svelte.dev/src/lib/actions/focus.js
deleted file mode 100644
index b688318bf..000000000
--- a/sites/kit.svelte.dev/src/lib/actions/focus.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/** @param {HTMLElement} node */
-export function focusable_children(node) {
-	const nodes = Array.from(
-		node.querySelectorAll(
-			'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
-		)
-	);
-
-	const index = nodes.indexOf(document.activeElement);
-
-	const update = (d) => {
-		let i = index + d;
-		i += nodes.length;
-		i %= nodes.length;
-
-		// @ts-expect-error
-		nodes[i].focus();
-	};
-
-	return {
-		/** @param {string} [selector] */
-		next: (selector) => {
-			const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)];
-
-			for (let i = 0; i < reordered.length; i += 1) {
-				if (!selector || reordered[i].matches(selector)) {
-					reordered[i].focus();
-					return;
-				}
-			}
-		},
-		/** @param {string} [selector] */
-		prev: (selector) => {
-			const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)];
-
-			for (let i = reordered.length - 2; i >= 0; i -= 1) {
-				if (!selector || reordered[i].matches(selector)) {
-					reordered[i].focus();
-					return;
-				}
-			}
-		},
-		update
-	};
-}
-
-export function trap(node) {
-	const handle_keydown = (e) => {
-		if (e.key === 'Tab') {
-			e.preventDefault();
-
-			const group = focusable_children(node);
-			if (e.shiftKey) {
-				group.prev();
-			} else {
-				group.next();
-			}
-		}
-	};
-
-	node.addEventListener('keydown', handle_keydown);
-
-	return {
-		destroy: () => {
-			node.removeEventListener('keydown', handle_keydown);
-		}
-	};
-}
diff --git a/sites/kit.svelte.dev/src/lib/search/Search.svelte b/sites/kit.svelte.dev/src/lib/search/Search.svelte
deleted file mode 100644
index c5feb5ef8..000000000
--- a/sites/kit.svelte.dev/src/lib/search/Search.svelte
+++ /dev/null
@@ -1,130 +0,0 @@
-<script>
-	import { browser } from '$app/environment';
-	import { searching, query } from './stores.js';
-
-	export let q = '';
-</script>
-
-<form class="search-container" action="/search">
-	<input
-		value={q}
-		on:input={(e) => {
-			$searching = true;
-			$query = e.target.value;
-			e.target.value = '';
-		}}
-		on:mousedown|preventDefault={() => ($searching = true)}
-		on:touchend|preventDefault={() => ($searching = true)}
-		type="search"
-		name="q"
-		placeholder="Search"
-		aria-label="Search"
-		spellcheck="false"
-	/>
-
-	{#if browser}
-		<div class="shortcut">
-			<kbd>{navigator.platform === 'MacIntel' ? '⌘' : 'Ctrl'}</kbd> <kbd>K</kbd>
-		</div>
-	{/if}
-</form>
-
-<style>
-	@keyframes fade-in {
-		from {
-			opacity: 0;
-		}
-		to {
-			opacity: 1;
-		}
-	}
-
-	.search-container {
-		position: relative;
-		display: flex;
-		align-items: center;
-		width: 100%;
-		height: 100%;
-	}
-
-	input {
-		padding: 0.5em 0.5em 0.4em 2em;
-		border: 1px solid var(--sk-back-translucent);
-		font-family: inherit;
-		font-size: 1.4rem;
-		/* text-align: center; */
-		appearance: none;
-		-webkit-appearance: none;
-		width: 100%;
-		height: 3.2rem;
-		border-radius: var(--sk-border-radius);
-		background: no-repeat 1rem 50% / 1em 1em url(../icons/search.svg);
-		color: var(--sk-text-3);
-	}
-
-	input:focus + .shortcut {
-		display: none;
-	}
-
-	input::placeholder {
-		font-size: 1.2rem;
-		text-transform: uppercase;
-	}
-
-	.shortcut {
-		color: var(--sk-text-3);
-		position: absolute;
-		top: calc(50% - 0.9rem);
-		right: 0;
-		width: 100%;
-		text-align: right;
-		pointer-events: none;
-		font-size: 1.2rem;
-		text-transform: uppercase;
-		animation: fade-in 0.2s;
-	}
-
-	kbd {
-		display: none;
-		background: var(--sk-back-2);
-		border: 1px solid var(--sk-back-translucent);
-		padding: 0.2rem 0.2rem 0rem 0.2rem;
-		color: var(--sk-text-3);
-		font-size: inherit;
-		font-family: inherit;
-		border-radius: 2px;
-	}
-
-	@media (min-width: 800px) {
-		.search-container {
-			width: 11rem;
-		}
-
-		.shortcut {
-			padding: 0 1.6rem 0 0;
-		}
-
-		input {
-			border-radius: 1.6rem;
-		}
-
-		input::placeholder {
-			opacity: 0;
-		}
-
-		/* we're using media query as an imperfect proxy for mobile/desktop */
-		kbd {
-			display: inline;
-		}
-	}
-
-	@media (min-width: 960px) {
-		.search-container {
-			width: 19rem;
-		}
-
-		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
deleted file mode 100644
index 841a8be11..000000000
--- a/sites/kit.svelte.dev/src/lib/search/SearchBox.svelte
+++ /dev/null
@@ -1,418 +0,0 @@
-<script>
-	import { onMount } from 'svelte';
-	import Icon from '@sveltejs/site-kit/components/Icon.svelte';
-	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 search = null;
-	let recent_searches = [];
-
-	let worker;
-	let ready = false;
-
-	let uid = 1;
-	const pending = new Set();
-
-	onMount(async () => {
-		worker = new SearchWorker();
-
-		worker.addEventListener('message', (event) => {
-			const { type, payload } = event.data;
-
-			if (type === 'ready') {
-				ready = true;
-			}
-
-			if (type === 'results') {
-				search = payload;
-			}
-
-			if (type === 'recents') {
-				recent_searches = payload;
-			}
-		});
-
-		worker.postMessage({
-			type: 'init',
-			payload: {
-				origin: location.origin
-			}
-		});
-	});
-
-	afterNavigate(() => {
-		// TODO this also needs to apply when only the hash changes
-		// (should before/afterNavigate fire at that time? unclear)
-		close();
-	});
-
-	function close() {
-		if ($searching) {
-			$searching = false;
-			const scroll = -parseInt(document.body.style.top || '0');
-			document.body.style.position = '';
-			document.body.style.top = '';
-			document.body.tabIndex = -1;
-			document.body.focus();
-			document.body.removeAttribute('tabindex');
-			window.scrollTo(0, scroll);
-		}
-
-		search = null;
-	}
-
-	/** @param {string} href */
-	function navigate(href) {
-		$recent = [href, ...$recent.filter((x) => x !== href)];
-		close();
-	}
-
-	$: if (ready) {
-		const id = uid++;
-		pending.add(id);
-
-		worker.postMessage({ type: 'query', id, payload: $query });
-	}
-
-	$: if (ready) {
-		worker.postMessage({ type: 'recents', payload: $recent });
-	}
-
-	$: if ($searching) {
-		document.body.style.top = `-${window.scrollY}px`;
-		document.body.style.position = 'fixed';
-	}
-</script>
-
-<svelte:window
-	on:keydown={(e) => {
-		if (e.key === 'k' && (navigator.platform === 'MacIntel' ? e.metaKey : e.ctrlKey)) {
-			e.preventDefault();
-			$query = '';
-
-			if ($searching) {
-				close();
-			} else {
-				$searching = true;
-			}
-		}
-
-		if (e.code === 'Escape') {
-			close();
-		}
-	}}
-/>
-
-{#if $searching && ready}
-	<div class="modal-background" on:click={close} />
-
-	<div
-		bind:this={modal}
-		class="modal"
-		on:keydown={(e) => {
-			if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
-				e.preventDefault();
-				const group = focusable_children(e.currentTarget);
-
-				// when using arrow keys (as opposed to tab), don't focus buttons
-				const selector = 'a, input';
-
-				if (e.key === 'ArrowDown') {
-					group.next(selector);
-				} else {
-					group.prev(selector);
-				}
-			}
-		}}
-		use:trap
-	>
-		<div class="search-box">
-			<!-- svelte-ignore a11y-autofocus -->
-			<input
-				autofocus
-				on:keydown={(e) => {
-					if (e.key === 'Enter') {
-						modal.querySelector('a[data-has-node]')?.click();
-					}
-				}}
-				on:input={(e) => {
-					$query = e.target.value;
-				}}
-				value={$query}
-				placeholder="Search"
-				aria-describedby="search-description"
-				aria-label="Search"
-				spellcheck="false"
-			/>
-
-			<button aria-label="Close" on:click={close}>
-				<Icon name="close" />
-			</button>
-
-			<span id="search-description" class="visually-hidden">Results will update as you type</span>
-
-			<div class="results">
-				{#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" class:empty={recent_searches.length === 0}>
-						{recent_searches.length ? 'Recent searches' : 'No recent searches'}
-					</h2>
-					{#if recent_searches.length}
-						<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.breadcrumbs.at(-1)}</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>
-		</div>
-	</div>
-{/if}
-
-<div aria-live="assertive" class="visually-hidden">
-	{#if $searching && search?.results.length === 0}
-		<p>No results</p>
-	{/if}
-</div>
-
-<style>
-	input {
-		font-family: inherit;
-		font-size: 1.6rem;
-		width: 100%;
-		padding: 1rem 6rem 0.5rem 1rem;
-		height: 5rem;
-		border: none;
-		border-bottom: 1px solid var(--sk-back-3);
-		font-weight: 600;
-		flex-shrink: 0;
-		background: var(--sk-back-2);
-		color: var(--sk-text-1);
-	}
-
-	input::selection {
-		background-color: var(--sk-back-translucent);
-	}
-
-	input::placeholder {
-		color: var(--sk-text-3);
-		opacity: 0.3;
-	}
-
-	input:focus-visible {
-		background: var(--sk-theme-2);
-		color: white;
-		outline: none;
-	}
-
-	input:focus-visible::placeholder {
-		color: rgba(255, 255, 255, 0.5);
-	}
-
-	button[aria-label='Close'] {
-		--size: 2rem;
-		position: absolute;
-		top: 0;
-		right: 0;
-		width: 5rem;
-		height: 5rem;
-		background: none;
-		color: var(--sk-text-2);
-	}
-
-	button[aria-label='Close']:focus-visible {
-		background: var(--sk-theme-2);
-		color: var(--sk-back-1);
-		outline: none;
-	}
-
-	input:focus-visible + button[aria-label='Close'] {
-		color: var(--sk-back-1);
-	}
-
-	ul {
-		margin: 0;
-	}
-
-	.modal-background,
-	.modal {
-		position: fixed;
-		left: 0;
-		top: 0;
-		width: 100%;
-		height: 100%;
-		z-index: 9999;
-	}
-
-	.modal-background {
-		background: var(--sk-back-1);
-		opacity: 0.7;
-	}
-
-	.modal {
-		display: flex;
-		justify-content: center;
-		align-items: center;
-		pointer-events: none;
-	}
-
-	.search-box {
-		position: relative;
-		height: calc(100% - 2rem);
-		width: calc(100vw - 2rem);
-		max-width: 50rem;
-		max-height: 50rem;
-		filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
-		border-radius: var(--sk-border-radius);
-		display: flex;
-		flex-direction: column;
-		overflow: hidden;
-	}
-
-	.search-box > * {
-		pointer-events: all;
-	}
-
-	.results {
-		overflow: auto;
-		overscroll-behavior-y: none;
-	}
-
-	.results-container {
-		background: var(--sk-back-2);
-		border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius);
-		pointer-events: all;
-	}
-
-	.info {
-		padding: 1rem;
-		font-size: 1.2rem;
-		font-weight: normal;
-		text-transform: uppercase;
-		background-color: var(--sk-back-2);
-		pointer-events: all;
-	}
-
-	.info.empty {
-		border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius);
-	}
-
-	a {
-		display: block;
-		text-decoration: none;
-		line-height: 1;
-		padding: 1rem;
-	}
-
-	a:hover {
-		background: rgba(0, 0, 0, 0.05);
-	}
-
-	a:focus {
-		background: var(--sk-theme-2);
-		color: var(--sk-back-1);
-		outline: none;
-	}
-
-	a small,
-	a strong {
-		display: block;
-		white-space: nowrap;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		line-height: 1;
-	}
-
-	a small {
-		font-size: 1rem;
-		text-transform: uppercase;
-		font-weight: 600;
-		color: var(--sk-text-3);
-	}
-
-	a strong {
-		font-size: 1.6rem;
-		color: var(--sk-text-2);
-		margin: 0.4rem 0;
-	}
-
-	a:focus small {
-		color: white;
-		opacity: 0.6;
-	}
-
-	a:focus strong {
-		color: white;
-	}
-
-	a strong :global(mark) {
-		background: var(--sk-theme-2);
-		color: var(--sk-text-3);
-		text-decoration: none;
-		border-radius: 1px;
-	}
-
-	li {
-		position: relative;
-	}
-
-	button[aria-label='Delete'] {
-		position: absolute;
-		top: 0;
-		right: 0;
-		width: 5rem;
-		height: 100%;
-		color: var(--sk-text-2);
-		opacity: 0.1;
-	}
-
-	a:focus + [aria-label='Delete'] {
-		color: var(--sk-back-1);
-	}
-
-	button[aria-label='Delete']:hover {
-		opacity: 1;
-		outline: none;
-	}
-
-	button[aria-label='Delete']:focus-visible {
-		background: var(--sk-theme-2);
-		color: var(--sk-text-1);
-		opacity: 1;
-		outline: none;
-	}
-</style>
diff --git a/sites/kit.svelte.dev/src/lib/search/SearchResultList.svelte b/sites/kit.svelte.dev/src/lib/search/SearchResultList.svelte
deleted file mode 100644
index 2fd143429..000000000
--- a/sites/kit.svelte.dev/src/lib/search/SearchResultList.svelte
+++ /dev/null
@@ -1,161 +0,0 @@
-<script>
-	import { createEventDispatcher } from 'svelte';
-
-	/** @type {import('./types').Tree[]} */
-	export let results;
-
-	/** @type {string} */
-	export let query;
-
-	const dispatch = createEventDispatcher();
-
-	/** @param {string} text */
-	function escape(text) {
-		return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
-	}
-
-	/**
-	 * @param {string} content
-	 * @param {string} query
-	 */
-	function excerpt(content, query) {
-		const index = content.toLowerCase().indexOf(query.toLowerCase());
-		if (index === -1) {
-			return escape(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 (result.href)}
-		<li>
-			<a
-				data-sveltekit-preload-data
-				href={result.href}
-				on:click={() => dispatch('select', { href: result.href })}
-				data-has-node={result.node ? true : undefined}
-			>
-				<strong>{@html excerpt(result.breadcrumbs[result.breadcrumbs.length - 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 var(--sk-back-5);
-	}
-
-	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(--sk-theme-2);
-		color: white;
-		outline: none;
-	}
-
-	a strong,
-	a span {
-		display: block;
-		white-space: nowrap;
-		line-height: 1;
-		overflow: hidden;
-		text-overflow: ellipsis;
-	}
-
-	a strong {
-		font-size: 1.6rem;
-		color: var(--sk-text-2);
-	}
-
-	a span {
-		font-size: 1.2rem;
-		color: #737373;
-		margin: 0.4rem 0 0 0;
-	}
-
-	a :global(mark) {
-		--highlight-color: rgba(255, 255, 0, 0.2);
-	}
-
-	a span :global(mark) {
-		background: none;
-		color: var(--sk-text-1);
-		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: var(--sk-text-1);
-		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
deleted file mode 100644
index 43b270e97..000000000
--- a/sites/kit.svelte.dev/src/lib/search/SearchResults.svelte
+++ /dev/null
@@ -1,28 +0,0 @@
-<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 if query}
-	<p class="info">No results</p>
-{/if}
-
-<style>
-	.info {
-		padding: 1rem;
-		font-size: 1.2rem;
-		font-weight: normal;
-		text-transform: uppercase;
-		background-color: var(--sk-back-2);
-		border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius);
-		pointer-events: all;
-		margin: 0;
-	}
-</style>
diff --git a/sites/kit.svelte.dev/src/lib/search/search.js b/sites/kit.svelte.dev/src/lib/search/search.js
deleted file mode 100644
index d4a3f02e3..000000000
--- a/sites/kit.svelte.dev/src/lib/search/search.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import flexsearch from 'flexsearch';
-
-// @ts-expect-error
-const Index = /** @type {import('flexsearch').Index} */ (flexsearch.Index) ?? 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();
-
-/** @param {import('./types').Block[]} blocks */
-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 Index({ tokenize: 'forward' }));
-
-	for (const block of blocks) {
-		const title = block.breadcrumbs.at(-1);
-		map.set(block.href, block);
-		// NOTE: we're not using a number as the ID here, but it is recommended:
-		// https://github.com/nextapps-de/flexsearch#use-numeric-ids
-		// If we were to switch to a number we would need a second map from ID to block
-		// We need to keep the existing one to allow looking up recent searches by URL even if docs change
-		// It's unclear how much browsers do string interning and how this might affect memory
-		// We'd probably want to test both implementations across browsers if memory usage becomes an issue
-		// TODO: fix the type by updating flexsearch after
-		// https://github.com/nextapps-de/flexsearch/pull/364 is merged and released
-		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 escaped = query.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
-	const regex = new RegExp(`(^|\\b)${escaped}`, 'i');
-
-	const blocks = indexes
-		.flatMap((index) => index.search(query))
-		.map(lookup)
-		.map((block, rank) => ({ block, rank }))
-		.sort((a, b) => {
-			const a_title_matches = regex.test(a.block.breadcrumbs.at(-1));
-			const b_title_matches = regex.test(b.block.breadcrumbs.at(-1));
-
-			// 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);
-}
-
-/**
- * @param {string[]} breadcrumbs
- * @param {import('./types').Block[]} blocks
- */
-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/stores.js b/sites/kit.svelte.dev/src/lib/search/stores.js
deleted file mode 100644
index cf41f6eea..000000000
--- a/sites/kit.svelte.dev/src/lib/search/stores.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { writable } from 'svelte/store';
-
-export const searching = writable(false);
-export const query = writable('');
-
-/**
- * @param {string} key
- * @param {any} fallback
- */
-function storable(key, fallback) {
-	try {
-		let value = key in localStorage ? JSON.parse(localStorage[key]) : fallback;
-
-		const store = writable(value);
-
-		const set = (new_value) => {
-			store.set((value = new_value));
-			localStorage[key] = JSON.stringify(value);
-		};
-
-		return {
-			subscribe: store.subscribe,
-			set,
-			update: (fn) => set(fn(value))
-		};
-	} catch {
-		return writable(fallback);
-	}
-}
-
-export const recent = storable('recent_searches', []);
diff --git a/sites/kit.svelte.dev/src/lib/search/types.d.ts b/sites/kit.svelte.dev/src/lib/search/types.d.ts
deleted file mode 100644
index fbbbdba23..000000000
--- a/sites/kit.svelte.dev/src/lib/search/types.d.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-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
deleted file mode 100644
index 2cb522774..000000000
--- a/sites/kit.svelte.dev/src/lib/workers/search.js
+++ /dev/null
@@ -1,26 +0,0 @@
-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 ba071448a..c2a4b8c3f 100644
--- a/sites/kit.svelte.dev/src/routes/+layout.svelte
+++ b/sites/kit.svelte.dev/src/routes/+layout.svelte
@@ -1,70 +1,52 @@
 <script>
-	import '@sveltejs/site-kit/styles/index.css';
 	import { browser } from '$app/environment';
 	import { base } from '$app/paths';
-	import { page, navigating } from '$app/stores';
-	import Icon from '@sveltejs/site-kit/components/Icon.svelte';
-	import Icons from '@sveltejs/site-kit/components/Icons.svelte';
-	import Nav from '@sveltejs/site-kit/components/Nav.svelte';
-	import NavItem from '@sveltejs/site-kit/components/NavItem.svelte';
-	import PreloadingIndicator from '@sveltejs/site-kit/components/PreloadingIndicator.svelte';
-	import SkipLink from '@sveltejs/site-kit/components/SkipLink.svelte';
-	import Search from '$lib/search/Search.svelte';
-	import SearchBox from '$lib/search/SearchBox.svelte';
+	import { page } from '$app/stores';
+	import { Icon, Nav, NavItem, Separator, Shell } from '@sveltejs/site-kit/components';
+	import { Search, SearchBox } from '@sveltejs/site-kit/search';
+	import '@sveltejs/site-kit/styles/index.css';
 	import Logo from './home/svelte-logo.svg';
 
 	let banner_height = '48px';
 </script>
 
-<Icons />
-
-{#if $navigating}
-	<PreloadingIndicator />
-{/if}
+<Shell banner_bottom_height={banner_height}>
+	<Nav logo={Logo} slot="top-nav">
+		<svelte:fragment slot="nav-center">
+			{#if $page.url.pathname !== '/search'}
+				<li><Search /></li>
+			{/if}
+		</svelte:fragment>
+
+		<svelte:fragment slot="nav-right">
+			<NavItem
+				selected={$page.url.pathname.startsWith(`${base}/docs`) || undefined}
+				href="{base}/docs">Docs</NavItem
+			>
+			<NavItem
+				selected={$page.url.pathname.startsWith(`${base}/faq`) || undefined}
+				href="{base}/faq">FAQ</NavItem
+			>
+
+			<Separator />
+
+			<NavItem external="https://svelte.dev">Svelte</NavItem>
+
+			<NavItem external="https://svelte.dev/chat" title="Discord Chat">
+				<span slot="small">Discord</span>
+				<Icon name="message-square" />
+			</NavItem>
+
+			<NavItem external="https://github.com/sveltejs/kit" title="GitHub Repo">
+				<span slot="small">GitHub</span>
+				<Icon name="github" />
+			</NavItem>
+		</svelte:fragment>
+	</Nav>
 
-<SkipLink href="#main" />
-
-<Nav {page} logo={Logo}>
-	<svelte:fragment slot="nav-center">
-		{#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 -->
-			<li><Search /></li>
-		{/if}
-	</svelte:fragment>
-
-	<svelte:fragment slot="nav-right">
-		<NavItem
-			selected={$page.url.pathname.startsWith(`${base}/docs`) || undefined}
-			href="{base}/docs">Docs</NavItem
-		>
-		<NavItem selected={$page.url.pathname.startsWith(`${base}/faq`) || undefined} href="{base}/faq"
-			>FAQ</NavItem
-		>
-
-		<li aria-hidden="true"><span class="separator" /></li>
-
-		<NavItem external="https://svelte.dev">Svelte</NavItem>
-
-		<NavItem external="https://svelte.dev/chat" title="Discord Chat">
-			<span class="small">Discord</span>
-			<span class="large"><Icon name="message-square" /></span>
-		</NavItem>
-
-		<NavItem external="https://github.com/sveltejs/kit" title="GitHub Repo">
-			<span class="small">GitHub</span>
-			<span class="large"><Icon name="github" /></span>
-		</NavItem>
-	</svelte:fragment>
-</Nav>
-
-<main id="main" style:--banner-footer-height={banner_height}>
 	<slot />
-</main>
 
-{#if banner_height !== '0px'}
-	<div class="banner" style:--banner-footer-height={banner_height}>
+	<div class="banner" slot="banner-bottom">
 		<a target="_blank" rel="noopener noreferrer" href="https://hack.sveltesociety.dev/">
 			<span class="small">
 				<strong>Announcing SvelteHack</strong> Participate →
@@ -75,25 +57,13 @@
 		</a>
 		<button on:click={() => (banner_height = '0px')}> ✕ </button>
 	</div>
-{/if}
+</Shell>
 
 {#if browser}
 	<SearchBox />
 {/if}
 
 <style>
-	:root {
-		--banner-footer-height: 48px;
-	}
-
-	main {
-		position: relative;
-		margin: 0 auto;
-		padding-top: var(--sk-nav-height);
-		padding-bottom: var(--banner-footer-height);
-		overflow: hidden;
-	}
-
 	.small {
 		display: inline;
 	}
@@ -102,15 +72,6 @@
 		display: none;
 	}
 
-	/* duplicating content from <Nav> — bit hacky but will do for now */
-	.separator {
-		display: block;
-		position: relative;
-		height: 1px;
-		margin: 0.5rem 0;
-		background: radial-gradient(circle at center, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.05));
-	}
-
 	@media (min-width: 800px) {
 		.small {
 			display: none;
@@ -119,35 +80,6 @@
 		.large {
 			display: inline;
 		}
-
-		.separator {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			background: none;
-			height: 100%;
-			margin: 0;
-			border: none;
-			text-align: center;
-		}
-
-		.separator::before {
-			content: '•';
-			margin: 0 0.3rem;
-			color: #ccc;
-		}
-	}
-
-	@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;
-		}
-	}
-
-	:global(body) {
-		font-size: 1.6rem !important;
 	}
 
 	li {
@@ -163,12 +95,6 @@
 		bottom: 0 !important;
 	}
 
-	@media (max-width: 830px) {
-		:global(aside) {
-			z-index: 9999 !important;
-		}
-	}
-
 	.banner {
 		--banner-bg: #ff4700;
 		--banner-color: white;
@@ -182,7 +108,7 @@
 		justify-content: center;
 		bottom: 0;
 		width: 100vw;
-		height: var(--banner-footer-height);
+		height: var(--sk-banner-bottom-height);
 		z-index: 999;
 	}
 
diff --git a/sites/kit.svelte.dev/src/routes/content.json/+server.js b/sites/kit.svelte.dev/src/routes/content.json/+server.js
index a757fd607..8807d9481 100644
--- a/sites/kit.svelte.dev/src/routes/content.json/+server.js
+++ b/sites/kit.svelte.dev/src/routes/content.json/+server.js
@@ -1,4 +1,4 @@
-import { content } from '$lib/search/content.server.js';
+import { content } from './content.server.js';
 import { json } from '@sveltejs/kit';
 
 export const prerender = true;
diff --git a/sites/kit.svelte.dev/src/lib/search/content.server.js b/sites/kit.svelte.dev/src/routes/content.json/content.server.js
similarity index 91%
rename from sites/kit.svelte.dev/src/lib/search/content.server.js
rename to sites/kit.svelte.dev/src/routes/content.json/content.server.js
index 9f51d8981..281b23038 100644
--- a/sites/kit.svelte.dev/src/lib/search/content.server.js
+++ b/sites/kit.svelte.dev/src/routes/content.json/content.server.js
@@ -1,9 +1,9 @@
 import fs from 'node:fs';
 import path from 'node:path';
 import glob from 'tiny-glob/sync.js';
-import { slugify } from '../docs/server';
-import { extract_frontmatter, transform } from '../docs/server/markdown.js';
-import { replace_placeholders } from '../docs/server/render.js';
+import { slugify } from '$lib/docs/server';
+import { extract_frontmatter, transform } from '$lib/docs/server/markdown.js';
+import { replace_placeholders } from '$lib/docs/server/render.js';
 
 const categories = [
 	{
@@ -20,7 +20,7 @@ const categories = [
 ];
 
 export function content() {
-	/** @type {import('$lib/search/types').Block[]} */
+	/** @type {import('@sveltejs/site-kit/search').Block[]} */
 	const blocks = [];
 
 	for (const category of categories) {
diff --git a/sites/kit.svelte.dev/src/routes/docs/+layout.svelte b/sites/kit.svelte.dev/src/routes/docs/+layout.svelte
index cff5b63c7..39d4f8714 100644
--- a/sites/kit.svelte.dev/src/routes/docs/+layout.svelte
+++ b/sites/kit.svelte.dev/src/routes/docs/+layout.svelte
@@ -45,7 +45,9 @@
 	@media (min-width: 832px) {
 		.toc-container {
 			width: var(--sidebar-width);
-			height: calc(100vh - var(--sk-nav-height) - var(--ts-toggle-height) - var(--banner-footer-height));
+			height: calc(
+				100vh - var(--sk-nav-height) - var(--ts-toggle-height) - var(--sk-banner-bottom-height)
+			);
 			position: fixed;
 			left: 0;
 			top: var(--sk-nav-height);
@@ -70,7 +72,7 @@
 		.ts-toggle {
 			position: fixed;
 			width: var(--sidebar-width);
-			bottom: var(--banner-footer-height);
+			bottom: var(--sk-banner-bottom-height);
 			z-index: 1;
 			margin-right: 0;
 			border-right: 1px solid var(--sk-back-5);
diff --git a/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.svelte b/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.svelte
index 696541b8a..0a0720893 100644
--- a/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.svelte
+++ b/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.svelte
@@ -1,6 +1,6 @@
 <script>
 	import { page } from '$app/stores';
-	import Icon from '@sveltejs/site-kit/components/Icon.svelte';
+	import { Icon } from '@sveltejs/site-kit/components';
 	import * as hovers from '$lib/docs/client/hovers.js';
 	import OnThisPage from './OnThisPage.svelte';
 
diff --git a/sites/kit.svelte.dev/src/routes/home/Deployment.svelte b/sites/kit.svelte.dev/src/routes/home/Deployment.svelte
index ff6b326ee..b7370b30a 100644
--- a/sites/kit.svelte.dev/src/routes/home/Deployment.svelte
+++ b/sites/kit.svelte.dev/src/routes/home/Deployment.svelte
@@ -1,5 +1,5 @@
 <script>
-	import Section from '@sveltejs/site-kit/components/Section.svelte';
+	import { Section } from '@sveltejs/site-kit/components';
 	import html5 from './logos/html5.svg';
 	import node from './logos/node.svg';
 	import vercel from './logos/vercel.svg';
diff --git a/sites/kit.svelte.dev/src/routes/home/Features.svelte b/sites/kit.svelte.dev/src/routes/home/Features.svelte
index 151abca90..0854a8bb0 100644
--- a/sites/kit.svelte.dev/src/routes/home/Features.svelte
+++ b/sites/kit.svelte.dev/src/routes/home/Features.svelte
@@ -1,5 +1,5 @@
 <script>
-	import Section from '@sveltejs/site-kit/components/Section.svelte';
+	import { Section } from '@sveltejs/site-kit/components';
 </script>
 
 <Section --background="var(--sk-back-1)">
diff --git a/sites/kit.svelte.dev/src/routes/home/Intro.svelte b/sites/kit.svelte.dev/src/routes/home/Intro.svelte
index e259c9e4d..c8b816394 100644
--- a/sites/kit.svelte.dev/src/routes/home/Intro.svelte
+++ b/sites/kit.svelte.dev/src/routes/home/Intro.svelte
@@ -1,5 +1,5 @@
 <script>
-	import Section from '@sveltejs/site-kit/components/Section.svelte';
+	import { Section } from '@sveltejs/site-kit/components';
 </script>
 
 <Section --background="var(--sk-back-1)">
diff --git a/sites/kit.svelte.dev/src/routes/home/Showcase.svelte b/sites/kit.svelte.dev/src/routes/home/Showcase.svelte
index 50a60a491..d6584461b 100644
--- a/sites/kit.svelte.dev/src/routes/home/Showcase.svelte
+++ b/sites/kit.svelte.dev/src/routes/home/Showcase.svelte
@@ -1,6 +1,6 @@
 <script>
 	import Image from '$lib/Image.svelte';
-	import Section from '@sveltejs/site-kit/components/Section.svelte';
+	import { Section } from '@sveltejs/site-kit/components';
 	import pudding from './showcase/pudding.png';
 	import pocketbase from './showcase/pocketbase.png';
 	import pronauns from './showcase/pronauns.png';
diff --git a/sites/kit.svelte.dev/src/routes/home/Svelte.svelte b/sites/kit.svelte.dev/src/routes/home/Svelte.svelte
index 7f0b44d4c..e896bfbd2 100644
--- a/sites/kit.svelte.dev/src/routes/home/Svelte.svelte
+++ b/sites/kit.svelte.dev/src/routes/home/Svelte.svelte
@@ -1,5 +1,5 @@
 <script>
-	import Section from '@sveltejs/site-kit/components/Section.svelte';
+	import { Section } from '@sveltejs/site-kit/components';
 	import Video from './Video.svelte';
 </script>
 
@@ -28,7 +28,9 @@
 			<p>
 				SvelteKit is built on Svelte, a UI framework that uses a compiler to let you write
 				breathtakingly concise components that do minimal work in the browser, using languages you
-				already know — HTML, CSS and JavaScript. <strong>It's a love letter to web development.</strong>
+				already know — HTML, CSS and JavaScript. <strong
+					>It's a love letter to web development.</strong
+				>
 			</p>
 
 			<p>
diff --git a/sites/kit.svelte.dev/src/routes/home/Try.svelte b/sites/kit.svelte.dev/src/routes/home/Try.svelte
index 600d3d15c..cea9e31e0 100644
--- a/sites/kit.svelte.dev/src/routes/home/Try.svelte
+++ b/sites/kit.svelte.dev/src/routes/home/Try.svelte
@@ -1,5 +1,5 @@
 <script>
-	import Section from '@sveltejs/site-kit/components/Section.svelte';
+	import { Section } from '@sveltejs/site-kit/components';
 </script>
 
 <Section --background="var(--background-2)">
diff --git a/sites/kit.svelte.dev/src/routes/search/+page.server.js b/sites/kit.svelte.dev/src/routes/search/+page.server.js
index 8c703deec..c7cd83244 100644
--- a/sites/kit.svelte.dev/src/routes/search/+page.server.js
+++ b/sites/kit.svelte.dev/src/routes/search/+page.server.js
@@ -1,4 +1,4 @@
-import { init, inited, search } from '$lib/search/search.js';
+import { init, inited, search } from '@sveltejs/site-kit/search';
 
 /** @type {import('./$types').PageServerLoad} */
 export async function load({ url, fetch }) {
diff --git a/sites/kit.svelte.dev/src/routes/search/+page.svelte b/sites/kit.svelte.dev/src/routes/search/+page.svelte
index f494432c9..bad2c3c75 100644
--- a/sites/kit.svelte.dev/src/routes/search/+page.svelte
+++ b/sites/kit.svelte.dev/src/routes/search/+page.svelte
@@ -1,7 +1,7 @@
 <script>
-	import SearchResults from '$lib/search/SearchResults.svelte';
+	import { SearchResults } from '@sveltejs/site-kit/search';
 
-	/** @type {import('./$types').Data} */
+	/** @type {import('./$types').PageData} */
 	export let data;
 </script>
 
diff --git a/sites/kit.svelte.dev/vite.config.js b/sites/kit.svelte.dev/vite.config.js
index 174e60bd8..681a07a03 100644
--- a/sites/kit.svelte.dev/vite.config.js
+++ b/sites/kit.svelte.dev/vite.config.js
@@ -26,6 +26,13 @@ const config = {
 		sveltekit()
 	],
 
+	ssr: {
+		noExternal: ['@sveltejs/site-kit']
+	},
+	optimizeDeps: {
+		exclude: ['@sveltejs/site-kit']
+	},
+
 	server: {
 		fs: {
 			strict: false


Test patch:
diff --git a/packages/kit/src/core/sync/write_types/test/actions/+page.server.js b/packages/kit/src/core/sync/write_types/test/actions/+page.server.js
new file mode 100644
index 000000000..46dd8f8f4
--- /dev/null
+++ b/packages/kit/src/core/sync/write_types/test/actions/+page.server.js
@@ -0,0 +1,87 @@
+import { fail } from '../../../../../../types/internal.js';
+
+let condition = false;
+
+export const actions = {
+	default: () => {
+		if (condition) {
+			return fail(400, {
+				fail: 'oops'
+			});
+		}
+
+		return {
+			success: true
+		};
+	},
+	successWithPayload: () => {
+		return {
+			id: 42,
+			username: 'John Doe',
+			profession: 'Svelte specialist'
+		};
+	},
+	successWithoutPayload: () => {},
+	failWithPayload: () => {
+		return fail(400, {
+			reason: {
+				error: {
+					code: /** @type {const} */ ('VALIDATION_FAILED')
+				}
+			}
+		});
+	},
+	failWithoutPayload: () => {
+		return fail(400);
+	}
+};
+
+/** @type {import('./.svelte-kit/types/src/core/sync/write_types/test/actions/$types').SubmitFunction} */
+const submit = () => {
+	return ({ result }) => {
+		if (result.type === 'success') {
+			// @ts-expect-error does only exist on `failure` result
+			result.data?.fail;
+			// @ts-expect-error unknown property
+			result.data?.something;
+
+			if (result.data && 'success' in result.data) {
+				result.data.success === true;
+				// @ts-expect-error should be of type `boolean`
+				result.data.success === 'success';
+				// @ts-expect-error does not exist in this branch
+				result.data.id;
+			}
+
+			if (result.data && 'id' in result.data) {
+				result.data.id === 42;
+				// @ts-expect-error should be of type `number`
+				result.data.id === 'John';
+				// @ts-expect-error does not exist in this branch
+				result.data.success;
+			}
+		}
+
+		if (result.type === 'failure') {
+			result.data;
+			// @ts-expect-error does only exist on `success` result
+			result.data.success;
+			// @ts-expect-error unknown property
+			result.data.unknown;
+
+			if (result.data && 'fail' in result.data) {
+				result.data.fail === '';
+				// @ts-expect-error does not exist in this branch
+				result.data.reason;
+			}
+
+			if (result.data && 'reason' in result.data) {
+				result.data.reason.error.code === 'VALIDATION_FAILED';
+				// @ts-expect-error should be a const
+				result.data.reason.error.code === '';
+				// @ts-expect-error does not exist in this branch
+				result.data.fail;
+			}
+		}
+	};
+};