Repository: sveltejs/kit
Pull request #6586 (2022-09-05 15:11:10)

[breaking] error handling rework
# How to migrate

### Step 1: Rename hooks file

There's now also a hooks file for the client. `kit.files.hooks` is therefore now `{ server: string; client: string }` instead of `string`. So if you have used that setting, you need to adjust your config:

```diff
// svelte.config.js
export default {
  kit: {
-    hooks: 'src/myhooks'
+   hooks: { server: 'src/myhooks' }
```

If you didn't use that setting, then by default the server hooks now live in `src/hooks.server`, so you need to rename the file:
```diff
|- src
-   |- hooks.js
+   |- hooks.server.js
```

### Step 2: Adjust types

The interface `HandleError` has been renamed to `HandleServerError`, to disambiguate it from the new interface `HandleClientError`. The error type has been changed to `unknown`. Previously it was typed `Error`, but we can't know if this is always the case, so we align with TypeScript's type in `catch` here. This likely means you need to do a type cast, either by checking if it indeed is an `Error`, or just trusting your (third party) code to always throw `Error`s:

```diff
- import type { HandleError } from '@sveltejs/kit';
+ import type { HandleServerError } from '@sveltejs/kit';

- export const handleError: HandleError = ({ error }) => {
+ export const handleError: HandleServerError = ({ error }) => {
+    if (error instanceof Error) {
         SuperCoolErrorService.log(error);
+   }      
};
```

### Step 3: Define the desired error shape

The shape of `$page.error` is now defined by the `handleError` hook, which exists on the server and the client. `$page.error` is of type `App.PageError`, which you can define yourself in `app.d.ts`:

```ts
declare namespace App {
  // ...
  interface PageError {
    message: string; // this property is always required, to provide a sensible fallback
    whateverYouWant: string;
  }
```

You then need to make sure that the `handleError` function in both `hooks.server.js` and `hooks.client.js` adhere to the type by returning that shape from the function:

```js
export function handleError({ error }) {
   // ...
   return {
     message: error.message,
     whateverYouWant: 'something else'
   };
}
```

If `handleError` is not defined or the return value is undefined, then SvelteKit falls back to `{ message: 'Internal Error' }`

# PR description

Implements #6499

- [x] `handleError` returns JSON
- [x] `App.PageData` type
- [x]  `handleError` client hook
- [x] docs
- [x] migration docs

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] 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.
- [ ] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [ ] 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. All changesets should be `patch` until SvelteKit 1.0


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

Latest commit: a039c7da43d7c26f38c9a3e9b3a1549e65299f39

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/error-rework?filename=.changeset/poor-dancers-beam.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A%22test-basics%22%3A%20patch%0A---%0A%0AhandlError%20returns%20App.PageData%20shape%0A)


Rich-Harris: Not a problem for now, but something weird is happening with the docs — perhaps related to the overload? `error` is included twice

<img width="759" alt="image" src="https://user-images.githubusercontent.com/1162160/188874878-34afc08e-7c99-48ef-9c83-eec7dd68e16d.png">


Patch:
diff --git a/documentation/docs/05-load.md b/documentation/docs/05-load.md
index 8671ac310..7bcf13c70 100644
--- a/documentation/docs/05-load.md
+++ b/documentation/docs/05-load.md
@@ -143,7 +143,7 @@ export async function load({ depends }) {
 - it can be used to make credentialed requests on the server, as it inherits the `cookie` and `authorization` headers for the page request
 - it can make relative requests on the server (ordinarily, `fetch` requires a URL with an origin when used in a server context)
 - internal requests (e.g. for `+server.js` routes) go direct to the handler function when running on the server, without the overhead of an HTTP call
-- during server-side rendering, the response will be captured and inlined into the rendered HTML. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](/docs/hooks#handle)
+- during server-side rendering, the response will be captured and inlined into the rendered HTML. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](/docs/hooks#hooks-server-js-handle)
 - during hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request
 
 > Cookies will only be passed through if the target host is the same as the SvelteKit application or a more specific subdomain of it.
@@ -308,7 +308,7 @@ export function load({ locals }) {
 }
 ```
 
-If an _unexpected_ error is thrown, SvelteKit will invoke [`handleError`](/docs/hooks#handleerror) and treat it as a 500 Internal Server Error.
+If an _unexpected_ error is thrown, SvelteKit will invoke [`handleError`](/docs/hooks#hooks-server-js-handleerror) and treat it as a 500 Internal Error.
 
 > In development, stack traces for unexpected errors are visible as `$page.error.stack`. In production, stack traces are hidden.
 
diff --git a/documentation/docs/07-hooks.md b/documentation/docs/07-hooks.md
index 0a5effb21..827d9e7a7 100644
--- a/documentation/docs/07-hooks.md
+++ b/documentation/docs/07-hooks.md
@@ -2,11 +2,20 @@
 title: Hooks
 ---
 
-An optional `src/hooks.js` (or `src/hooks.ts`, or `src/hooks/index.js`) file exports three functions, all optional, that run on the server — `handle`, `handleError` and `handleFetch`.
+'Hooks' are app-wide functions you declare that SvelteKit will call in response to specific events, giving you fine-grained control over the framework's behaviour.
 
-> The location of this file can be [configured](/docs/configuration) as `config.kit.files.hooks`
+There are two hooks files, both optional:
 
-### handle
+- `src/hooks.server.js` — your app's server hooks
+- `src/hooks.client.js` — your app's client hooks
+
+> You can configure the location of these files with [`config.kit.files.hooks`](/docs/configuration#files).
+
+### Server hooks
+
+The following hooks can be added to `src/hooks.server.js`:
+
+#### handle
 
 This function runs every time the SvelteKit server receives a [request](/docs/web-standards#fetch-apis-request) — whether that happens while the app is running, or during [prerendering](/docs/page-options#prerender) — and determines the [response](/docs/web-standards#fetch-apis-response). It receives an `event` object representing the request and a function called `resolve`, which renders the route and generates a `Response`. This allows you to modify response headers or bodies, or bypass SvelteKit entirely (for implementing routes programmatically, for example).
 
@@ -79,47 +88,9 @@ export async function handle({ event, resolve }) {
 }
 ```
 
-### handleError
-
-If an error is thrown during loading or rendering, this function will be called with the `error` and the `event` that caused it. This allows you to send data to an error tracking service, or to customise the formatting before printing the error to the console.
-
-During development, if an error occurs because of a syntax error in your Svelte code, a `frame` property will be appended highlighting the location of the error.
-
-If unimplemented, SvelteKit will log the error with default formatting.
+#### handleFetch
 
-```js
-/// file: src/hooks.js
-// @filename: ambient.d.ts
-const Sentry: any;
-
-// @filename: index.js
-// ---cut---
-/** @type {import('@sveltejs/kit').HandleError} */
-export function handleError({ error, event }) {
-	// example integration with https://sentry.io/
-	Sentry.captureException(error, { event });
-}
-```
-
-> `handleError` is only called for _unexpected_ errors. It is not called for errors created with the [`error`](/docs/modules#sveltejs-kit-error) function imported from `@sveltejs/kit`, as these are _expected_ errors.
-
-### handleFetch
-
-This function allows you to modify (or replace) a `fetch` request that happens inside a `load` function that runs on the server (or during pre-rendering).
-
-For example, you might need to include custom headers that are added by a proxy that sits in front of your app:
-
-```js
-// @errors: 2345
-/** @type {import('@sveltejs/kit').HandleFetch} */
-export async function handleFetch({ event, request, fetch }) {
-	const name = 'x-geolocation-city';
-	const value = event.request.headers.get(name);
-	request.headers.set(name, value);
-
-	return fetch(request);
-}
-```
+This function allows you to modify (or replace) a `fetch` request for an external resource that happens inside a `load` function that runs on the server (or during pre-rendering).
 
 Or your `load` function might make a request to a public URL like `https://api.yourapp.com` when the user performs a client-side navigation to the respective page, but during SSR it might make sense to hit the API directly (bypassing whatever proxies and load balancers sit between it and the public internet).
 
@@ -138,7 +109,7 @@ export async function handleFetch({ request, fetch }) {
 }
 ```
 
-#### Credentials
+**Credentials**
 
 For same-origin requests, SvelteKit's `fetch` implementation will forward `cookie` and `authorization` headers unless the `credentials` option is set to `"omit"`.
 
@@ -157,3 +128,40 @@ export async function handleFetch({ event, request, fetch }) {
 	return fetch(request);
 }
 ```
+
+### Shared hooks
+
+The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:
+
+#### handleError
+
+If an unexpected error is thrown during loading or rendering, this function will be called with the `error` and the `event`. This allows for two things:
+
+- you can log the error
+- you can generate a custom representation of the error that is safe to show to users, omitting sensitive details like messages and stack traces. The returned value, which defaults to `{ message: 'Internal Error' }`, becomes the value of `$page.error`. To make this type-safe, you can customize the expected shape by declaring an `App.PageError` interface (which must include `message: string`, to guarantee sensible fallback behavior).
+
+```js
+/// file: src/hooks.server.js
+// @errors: 2322 2571
+// @filename: ambient.d.ts
+const Sentry: any;
+
+// @filename: index.js
+// ---cut---
+/** @type {import('@sveltejs/kit').HandleServerError} */
+export function handleError({ error, event }) {
+	// example integration with https://sentry.io/
+	Sentry.captureException(error, { event });
+
+	return {
+		message: 'Whoops!',
+		code: error.code ?? 'UNKNOWN'
+	};
+}
+```
+
+> In `src/hooks.client.js`, the type of `handleError` is `HandleClientError` instead of `HandleServerError`, and `event` is a `NavigationEvent` rather than a `RequestEvent`.
+
+This function is not called for _expected_ errors (those thrown with the [`error`](/docs/modules#sveltejs-kit-error) function imported from `@sveltejs/kit`).
+
+During development, if an error occurs because of a syntax error in your Svelte code, a `frame` property will be appended highlighting the location of the error.
\ No newline at end of file
diff --git a/documentation/docs/15-configuration.md b/documentation/docs/15-configuration.md
index 535aedb03..ad5dd2bd6 100644
--- a/documentation/docs/15-configuration.md
+++ b/documentation/docs/15-configuration.md
@@ -34,7 +34,10 @@ const config = {
 		},
 		files: {
 			assets: 'static',
-			hooks: 'src/hooks',
+			hooks: {
+				client: 'src/hooks.client',
+				server: 'src/hooks.server'
+			},
 			lib: 'src/lib',
 			params: 'src/params',
 			routes: 'src/routes',
@@ -179,7 +182,7 @@ Environment variable configuration:
 An object containing zero or more of the following `string` values:
 
 - `assets` — a place to put static files that should have stable URLs and undergo no processing, such as `favicon.ico` or `manifest.json`
-- `hooks` — the location of your hooks module (see [Hooks](/docs/hooks))
+- `hooks` — the location of your client and server hooks (see [Hooks](/docs/hooks))
 - `lib` — your app's internal library, accessible throughout the codebase as `$lib`
 - `params` — a directory containing [parameter matchers](/docs/routing#advanced-routing-matching)
 - `routes` — the files that define the structure of your app (see [Routing](/docs/routing))
@@ -296,7 +299,7 @@ Whether to remove, append, or ignore trailing slashes when resolving URLs (note
 
 This option also affects [prerendering](/docs/page-options#prerender). If `trailingSlash` is `always`, a route like `/about` will result in an `about/index.html` file, otherwise it will create `about.html`, mirroring static webserver conventions.
 
-> Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](/docs/hooks#handle) function.
+> Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](/docs/hooks#hooks-server-js-handle) function.
 
 ### version
 
diff --git a/documentation/docs/17-seo.md b/documentation/docs/17-seo.md
index 6642cf509..0a6bb5cf1 100644
--- a/documentation/docs/17-seo.md
+++ b/documentation/docs/17-seo.md
@@ -8,7 +8,7 @@ The most important aspect of SEO is to create high-quality content that is widel
 
 #### SSR
 
-While search engines have got better in recent years at indexing content that was rendered with client-side JavaScript, server-side rendered content is indexed more frequently and reliably. SvelteKit employs SSR by default, and while you can disable it in [`handle`](/docs/hooks#handle), you should leave it on unless you have a good reason not to.
+While search engines have got better in recent years at indexing content that was rendered with client-side JavaScript, server-side rendered content is indexed more frequently and reliably. SvelteKit employs SSR by default, and while you can disable it in [`handle`](/docs/hooks#hooks-server-js-handle), you should leave it on unless you have a good reason not to.
 
 > SvelteKit's rendering is highly configurable and you can implement [dynamic rendering](https://developers.google.com/search/docs/advanced/javascript/dynamic-rendering) if necessary. It's not generally recommended, since SSR has other benefits beyond SEO.
 
diff --git a/documentation/docs/19-accessibility.md b/documentation/docs/19-accessibility.md
index b2ca4c9aa..b015eb7e5 100644
--- a/documentation/docs/19-accessibility.md
+++ b/documentation/docs/19-accessibility.md
@@ -54,7 +54,7 @@ By default, SvelteKit's page template sets the default language of the document
 <html lang="de">
 ```
 
-If your content is available in multiple languages, you should set the `lang` attribute based on the language of the current page. You can do this with SvelteKit's [handle hook](/docs/hooks#handle):
+If your content is available in multiple languages, you should set the `lang` attribute based on the language of the current page. You can do this with SvelteKit's [handle hook](/docs/hooks#hooks-server-js-handle):
 
 ```html
 /// file: src/app.html
diff --git a/documentation/docs/80-migrating.md b/documentation/docs/80-migrating.md
index bc3449408..f3b17d1e3 100644
--- a/documentation/docs/80-migrating.md
+++ b/documentation/docs/80-migrating.md
@@ -150,7 +150,7 @@ See [the FAQ](/faq#integrations) for detailed information about integrations.
 
 #### HTML minifier
 
-Sapper includes `html-minifier` by default. SvelteKit does not include this, but it can be added as a [hook](/docs/hooks#handle):
+Sapper includes `html-minifier` by default. SvelteKit does not include this, but it can be added as a [hook](/docs/hooks#hooks-server-js-handle):
 
 ```js
 // @filename: ambient.d.ts
diff --git a/packages/adapter-cloudflare/README.md b/packages/adapter-cloudflare/README.md
index 55d73668f..ac335d69b 100644
--- a/packages/adapter-cloudflare/README.md
+++ b/packages/adapter-cloudflare/README.md
@@ -85,7 +85,7 @@ Functions contained in the `/functions` directory at the project's root will _no
 
 The [`_headers` and `_redirects`](config files) files specific to Cloudflare Pages can be used for static asset responses (like images) by putting them into the `/static` folder.
 
-However, they will have no effect on responses dynamically rendered by SvelteKit, which should return custom headers or redirect responses from [endpoints](https://kit.svelte.dev/docs/routing#endpoints) or with the [`handle`](https://kit.svelte.dev/docs/hooks#handle) hook.
+However, they will have no effect on responses dynamically rendered by SvelteKit, which should return custom headers or redirect responses from [endpoints](https://kit.svelte.dev/docs/routing#endpoints) or with the [`handle`](https://kit.svelte.dev/docs/hooks#hooks-server-js-handle) hook.
 
 ## Changelog
 
diff --git a/packages/create-svelte/templates/default/src/app.d.ts b/packages/create-svelte/templates/default/src/app.d.ts
index d7506f89a..89fa05e74 100644
--- a/packages/create-svelte/templates/default/src/app.d.ts
+++ b/packages/create-svelte/templates/default/src/app.d.ts
@@ -8,5 +8,7 @@ declare namespace App {
 
 	// interface PageData {}
 
+	// interface PageError {}
+
 	// interface Platform {}
 }
diff --git a/packages/create-svelte/templates/default/src/hooks.ts b/packages/create-svelte/templates/default/src/hooks.server.ts
similarity index 100%
rename from packages/create-svelte/templates/default/src/hooks.ts
rename to packages/create-svelte/templates/default/src/hooks.server.ts
diff --git a/packages/create-svelte/templates/skeleton/src/app.d.ts b/packages/create-svelte/templates/skeleton/src/app.d.ts
index 14ac5d1b5..6f53deca8 100644
--- a/packages/create-svelte/templates/skeleton/src/app.d.ts
+++ b/packages/create-svelte/templates/skeleton/src/app.d.ts
@@ -4,5 +4,6 @@
 declare namespace App {
 	// interface Locals {}
 	// interface PageData {}
+	// interface PageError {}
 	// interface Platform {}
 }
diff --git a/packages/create-svelte/templates/skeletonlib/src/app.d.ts b/packages/create-svelte/templates/skeletonlib/src/app.d.ts
index 3a4446871..2c2f9ec4f 100644
--- a/packages/create-svelte/templates/skeletonlib/src/app.d.ts
+++ b/packages/create-svelte/templates/skeletonlib/src/app.d.ts
@@ -6,5 +6,6 @@
 declare namespace App {
 	// interface Locals {}
 	// interface PageData {}
+	// interface PageError {}
 	// interface Platform {}
 }
diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js
index 3f5d53dc4..39f4160cd 100644
--- a/packages/kit/src/core/config/index.js
+++ b/packages/kit/src/core/config/index.js
@@ -77,8 +77,13 @@ function process_config(config, { cwd = process.cwd() } = {}) {
 		// TODO remove for 1.0
 		if (key === 'template') continue;
 
-		// @ts-expect-error this is typescript at its stupidest
-		validated.kit.files[key] = path.resolve(cwd, validated.kit.files[key]);
+		if (key === 'hooks') {
+			validated.kit.files.hooks.client = path.resolve(cwd, validated.kit.files.hooks.client);
+			validated.kit.files.hooks.server = path.resolve(cwd, validated.kit.files.hooks.server);
+		} else {
+			// @ts-expect-error
+			validated.kit.files[key] = path.resolve(cwd, validated.kit.files[key]);
+		}
 	}
 
 	if (!fs.existsSync(validated.kit.files.errorTemplate)) {
diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js
index 4ca04f1d1..4285b614e 100644
--- a/packages/kit/src/core/config/index.spec.js
+++ b/packages/kit/src/core/config/index.spec.js
@@ -82,7 +82,10 @@ const get_defaults = (prefix = '') => ({
 		},
 		files: {
 			assets: join(prefix, 'static'),
-			hooks: join(prefix, 'src/hooks'),
+			hooks: {
+				client: join(prefix, 'src/hooks.client'),
+				server: join(prefix, 'src/hooks.server')
+			},
 			lib: join(prefix, 'src/lib'),
 			params: join(prefix, 'src/params'),
 			routes: join(prefix, 'src/routes'),
diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js
index e5e84ec4c..dbffb030d 100644
--- a/packages/kit/src/core/config/options.js
+++ b/packages/kit/src/core/config/options.js
@@ -141,7 +141,19 @@ const options = object(
 
 			files: object({
 				assets: string('static'),
-				hooks: string(join('src', 'hooks')),
+				hooks: (input, keypath) => {
+					// TODO remove this for the 1.0 release
+					if (typeof input === 'string') {
+						throw new Error(
+							`${keypath} is an object with { server: string, client: string } now. See the PR for more information: https://github.com/sveltejs/kit/pull/6586`
+						);
+					}
+
+					return object({
+						client: string(join('src', 'hooks.client')),
+						server: string(join('src', 'hooks.server'))
+					})(input, keypath);
+				},
 				lib: string(join('src', 'lib')),
 				params: string(join('src', 'params')),
 				routes: string(join('src', 'routes')),
diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js
index 153923d7a..5949de8fa 100644
--- a/packages/kit/src/core/sync/sync.js
+++ b/packages/kit/src/core/sync/sync.js
@@ -26,7 +26,7 @@ export async function create(config) {
 
 	const output = path.join(config.kit.outDir, 'generated');
 
-	write_client_manifest(manifest_data, output);
+	write_client_manifest(config, manifest_data, output);
 	write_root(manifest_data, output);
 	write_matchers(manifest_data, output);
 	await write_all_types(config, manifest_data);
diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js
index 2fc49b937..89aa584a6 100644
--- a/packages/kit/src/core/sync/write_client_manifest.js
+++ b/packages/kit/src/core/sync/write_client_manifest.js
@@ -1,14 +1,16 @@
 import { relative } from 'path';
+import { posixify, resolve_entry } from '../../utils/filesystem.js';
 import { s } from '../../utils/misc.js';
 import { trim, write_if_changed } from './utils.js';
 
 /**
  * Writes the client manifest to disk. The manifest is used to power the router. It contains the
  * list of routes and corresponding Svelte components (i.e. pages and layouts).
+ * @param {import('types').ValidatedConfig} config
  * @param {import('types').ManifestData} manifest_data
  * @param {string} output
  */
-export function write_client_manifest(manifest_data, output) {
+export function write_client_manifest(config, manifest_data, output) {
 	/**
 	 * Creates a module that exports a `CSRPageNode`
 	 * @param {import('types').PageNode} node
@@ -78,10 +80,14 @@ export function write_client_manifest(manifest_data, output) {
 			.join(',\n\t\t')}
 	}`.replace(/^\t/gm, '');
 
+	const hooks_file = resolve_entry(config.kit.files.hooks.client);
+
 	// String representation of __GENERATED__/client-manifest.js
 	write_if_changed(
 		`${output}/client-manifest.js`,
 		trim(`
+			${hooks_file ? `import * as client_hooks from '${posixify(relative(output, hooks_file))}';` : ''}
+
 			export { matchers } from './client-matchers.js';
 
 			export const nodes = [${nodes}];
@@ -89,6 +95,12 @@ export function write_client_manifest(manifest_data, output) {
 			export const server_loads = [${[...layouts_with_server_load].join(',')}];
 
 			export const dictionary = ${dictionary};
+
+			export const hooks = {
+				handleError: ${
+					hooks_file ? 'client_hooks.handleError || ' : ''
+				}(({ error }) => { console.error(error); return { message: 'Internal Error' }; }),
+			};
 		`)
 	);
 }
diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js
index ab5344732..1f9ed9b2e 100644
--- a/packages/kit/src/exports/index.js
+++ b/packages/kit/src/exports/index.js
@@ -1,22 +1,17 @@
 import { HttpError, Redirect, ValidationError } from '../runtime/control.js';
 
+// For some reason we need to type the params as well here,
+// JSdoc doesn't seem to like @type with function overloads
 /**
- * Creates an `HttpError` object with an HTTP status code and an optional message.
- * This object, if thrown during request handling, will cause SvelteKit to
- * return an error response without invoking `handleError`
+ * @type {import('@sveltejs/kit').error}
  * @param {number} status
- * @param {string | undefined} [message]
+ * @param {any} message
  */
 export function error(status, message) {
 	return new HttpError(status, message);
 }
 
-/**
- * Creates a `Redirect` object. If thrown during request handling, SvelteKit will
- * return a redirect response.
- * @param {number} status
- * @param {string} location
- */
+/** @type {import('@sveltejs/kit').redirect} */
 export function redirect(status, location) {
 	if (isNaN(status) || status < 300 || status > 399) {
 		throw new Error('Invalid status code');
@@ -25,11 +20,7 @@ export function redirect(status, location) {
 	return new Redirect(status, location);
 }
 
-/**
- * Generates a JSON `Response` object from the supplied data.
- * @param {any} data
- * @param {ResponseInit} [init]
- */
+/** @type {import('@sveltejs/kit').json} */
 export function json(data, init) {
 	// TODO deprecate this in favour of `Response.json` when it's
 	// more widely supported
diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js
index e8a9d8eb1..11e1390fb 100644
--- a/packages/kit/src/exports/vite/build/build_server.js
+++ b/packages/kit/src/exports/vite/build/build_server.js
@@ -1,7 +1,7 @@
 import fs from 'fs';
 import path from 'path';
-import { mkdirp, posixify } from '../../../utils/filesystem.js';
-import { get_vite_config, merge_vite_configs, resolve_entry } from '../utils.js';
+import { mkdirp, posixify, resolve_entry } from '../../../utils/filesystem.js';
+import { get_vite_config, merge_vite_configs } from '../utils.js';
 import { load_error_page, load_template } from '../../../core/config/index.js';
 import { runtime_directory } from '../../../core/utils.js';
 import { create_build, find_deps, get_default_build_config, is_http_method } from './utils.js';
@@ -58,9 +58,8 @@ export class Server {
 				check_origin: ${s(config.kit.csrf.checkOrigin)},
 			},
 			dev: false,
-			get_stack: error => String(error), // for security
 			handle_error: (error, event) => {
-				this.options.hooks.handleError({
+				return this.options.hooks.handleError({
 					error,
 					event,
 
@@ -69,8 +68,7 @@ export class Server {
 					get request() {
 						throw new Error('request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details');
 					}
-				});
-				error.stack = this.options.get_stack(error);
+				}) ?? { message: 'Internal Error' };
 			},
 			hooks: null,
 			manifest,
@@ -158,7 +156,24 @@ export async function build_server(options, client) {
 		service_worker_entry_file
 	} = options;
 
-	let hooks_file = resolve_entry(config.kit.files.hooks);
+	let hooks_file = resolve_entry(config.kit.files.hooks.server);
+
+	// TODO remove for 1.0
+	if (!hooks_file) {
+		const old_file = resolve_entry(path.join(process.cwd(), 'src', 'hooks'));
+		if (old_file && fs.existsSync(old_file)) {
+			throw new Error(
+				`Rename your server hook file from ${posixify(
+					path.relative(process.cwd(), old_file)
+				)} to ${posixify(
+					path.relative(process.cwd(), config.kit.files.hooks.server)
+				)}.${path.extname(
+					old_file
+				)} (because there's also client hooks now). See the PR for more information: https://github.com/sveltejs/kit/pull/6586`
+			);
+		}
+	}
+
 	if (!hooks_file || !fs.existsSync(hooks_file)) {
 		hooks_file = path.join(config.kit.outDir, 'build/hooks.js');
 		fs.writeFileSync(hooks_file, '');
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index c78c02403..2896969af 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -6,12 +6,12 @@ import { URL } from 'url';
 import { getRequest, setResponse } from '../../../exports/node/index.js';
 import { installPolyfills } from '../../../exports/node/polyfills.js';
 import { coalesce_to_error } from '../../../utils/error.js';
-import { posixify } from '../../../utils/filesystem.js';
+import { posixify, resolve_entry } from '../../../utils/filesystem.js';
 import { load_error_page, load_template } from '../../../core/config/index.js';
 import { SVELTE_KIT_ASSETS } from '../../../constants.js';
 import * as sync from '../../../core/sync/sync.js';
 import { get_mime_lookup, runtime_base, runtime_prefix } from '../../../core/utils.js';
-import { prevent_illegal_vite_imports, resolve_entry } from '../utils.js';
+import { prevent_illegal_vite_imports } from '../utils.js';
 import { compact } from '../../../utils/array.js';
 import { normalizePath } from 'vite';
 
@@ -311,11 +311,28 @@ export async function dev(vite, vite_config, svelte_config) {
 					);
 				}
 
-				/** @type {Partial<import('types').Hooks>} */
-				const user_hooks = resolve_entry(svelte_config.kit.files.hooks)
-					? await vite.ssrLoadModule(`/${svelte_config.kit.files.hooks}`)
+				const hooks_file = svelte_config.kit.files.hooks.server;
+				/** @type {Partial<import('types').ServerHooks>} */
+				const user_hooks = resolve_entry(hooks_file)
+					? await vite.ssrLoadModule(`/${hooks_file}`)
 					: {};
 
+				// TODO remove for 1.0
+				if (!resolve_entry(hooks_file)) {
+					const old_file = resolve_entry(path.join(process.cwd(), 'src', 'hooks'));
+					if (old_file && fs.existsSync(old_file)) {
+						throw new Error(
+							`Rename your server hook file from ${posixify(
+								path.relative(process.cwd(), old_file)
+							)} to ${posixify(
+								path.relative(process.cwd(), svelte_config.kit.files.hooks.server)
+							)}.${path.extname(
+								old_file
+							)} (because there's also client hooks now). See the PR for more information: https://github.com/sveltejs/kit/pull/6586`
+						);
+					}
+				}
+
 				const handle = user_hooks.handle || (({ event, resolve }) => resolve(event));
 
 				// TODO remove for 1.0
@@ -326,12 +343,13 @@ export async function dev(vite, vite_config, svelte_config) {
 					);
 				}
 
-				/** @type {import('types').Hooks} */
+				/** @type {import('types').ServerHooks} */
 				const hooks = {
 					handle,
 					handleError:
 						user_hooks.handleError ||
-						(({ /** @type {Error & { frame?: string }} */ error }) => {
+						(({ error: e }) => {
+							const error = /** @type {Error & { frame?: string }} */ (e);
 							console.error(colors.bold().red(error.message));
 							if (error.frame) {
 								console.error(colors.gray(error.frame));
@@ -390,28 +408,29 @@ export async function dev(vite, vite_config, svelte_config) {
 							check_origin: svelte_config.kit.csrf.checkOrigin
 						},
 						dev: true,
-						get_stack: (error) => fix_stack_trace(error),
 						handle_error: (error, event) => {
-							hooks.handleError({
-								error: new Proxy(error, {
-									get: (target, property) => {
-										if (property === 'stack') {
-											return fix_stack_trace(error);
+							return (
+								hooks.handleError({
+									error: new Proxy(error, {
+										get: (target, property) => {
+											if (property === 'stack') {
+												return fix_stack_trace(error);
+											}
+
+											return Reflect.get(target, property, target);
 										}
-
-										return Reflect.get(target, property, target);
+									}),
+									event,
+
+									// TODO remove for 1.0
+									// @ts-expect-error
+									get request() {
+										throw new Error(
+											'request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details'
+										);
 									}
-								}),
-								event,
-
-								// TODO remove for 1.0
-								// @ts-expect-error
-								get request() {
-									throw new Error(
-										'request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details'
-									);
-								}
-							});
+								}) ?? { message: 'Internal Error' }
+							);
 						},
 						hooks,
 						manifest,
diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js
index 87bff2743..b7eda46b4 100644
--- a/packages/kit/src/exports/vite/index.js
+++ b/packages/kit/src/exports/vite/index.js
@@ -4,7 +4,7 @@ import path from 'node:path';
 import colors from 'kleur';
 import { svelte } from '@sveltejs/vite-plugin-svelte';
 import * as vite from 'vite';
-import { mkdirp, posixify, rimraf } from '../../utils/filesystem.js';
+import { mkdirp, posixify, resolve_entry, rimraf } from '../../utils/filesystem.js';
 import * as sync from '../../core/sync/sync.js';
 import { build_server } from './build/build_server.js';
 import { build_service_worker } from './build/build_service_worker.js';
@@ -14,7 +14,7 @@ import { generate_manifest } from '../../core/generate_manifest/index.js';
 import { runtime_directory, logger } from '../../core/utils.js';
 import { find_deps, get_default_build_config } from './build/utils.js';
 import { preview } from './preview/index.js';
-import { get_aliases, resolve_entry, prevent_illegal_rollup_imports, get_env } from './utils.js';
+import { get_aliases, prevent_illegal_rollup_imports, get_env } from './utils.js';
 import { fileURLToPath } from 'node:url';
 import { create_static_module, create_dynamic_module } from '../../core/env.js';
 
@@ -220,6 +220,21 @@ function kit() {
 				return new_config;
 			}
 
+			const allow = new Set([
+				svelte_config.kit.files.lib,
+				svelte_config.kit.files.routes,
+				svelte_config.kit.outDir,
+				path.resolve(cwd, 'src'), // TODO this isn't correct if user changed all his files to sth else than src (like in test/options)
+				path.resolve(cwd, 'node_modules'),
+				path.resolve(vite.searchForWorkspaceRoot(cwd), 'node_modules')
+			]);
+			// We can only add directories to the allow list, so we find out
+			// if there's a client hooks file and pass its directory
+			const client_hooks = resolve_entry(svelte_config.kit.files.hooks.client);
+			if (client_hooks) {
+				allow.add(path.dirname(client_hooks));
+			}
+
 			// dev and preview config can be shared
 			/** @type {import('vite').UserConfig} */
 			const result = {
@@ -243,16 +258,7 @@ function kit() {
 				root: cwd,
 				server: {
 					fs: {
-						allow: [
-							...new Set([
-								svelte_config.kit.files.lib,
-								svelte_config.kit.files.routes,
-								svelte_config.kit.outDir,
-								path.resolve(cwd, 'src'),
-								path.resolve(cwd, 'node_modules'),
-								path.resolve(vite.searchForWorkspaceRoot(cwd), 'node_modules')
-							])
-						]
+						allow: [...allow]
 					},
 					watch: {
 						ignored: [
diff --git a/packages/kit/src/exports/vite/utils.js b/packages/kit/src/exports/vite/utils.js
index 7559915e4..fc4ff2159 100644
--- a/packages/kit/src/exports/vite/utils.js
+++ b/packages/kit/src/exports/vite/utils.js
@@ -1,4 +1,3 @@
-import fs from 'fs';
 import path from 'path';
 import { loadConfigFromFile, loadEnv, normalizePath } from 'vite';
 import { runtime_directory } from '../../core/utils.js';
@@ -367,35 +366,6 @@ function escape_for_regexp(str) {
 	return str.replace(/[.*+?^${}()|[\]\\]/g, (match) => '\\' + match);
 }
 
-/**
- * Given an entry point like [cwd]/src/hooks, returns a filename like [cwd]/src/hooks.js or [cwd]/src/hooks/index.js
- * @param {string} entry
- * @returns {string|null}
- */
-export function resolve_entry(entry) {
-	if (fs.existsSync(entry)) {
-		const stats = fs.statSync(entry);
-		if (stats.isDirectory()) {
-			return resolve_entry(path.join(entry, 'index'));
-		}
-
-		return entry;
-	} else {
-		const dir = path.dirname(entry);
-
-		if (fs.existsSync(dir)) {
-			const base = path.basename(entry);
-			const files = fs.readdirSync(dir);
-
-			const found = files.find((file) => file.replace(/\.[^.]+$/, '') === base);
-
-			if (found) return path.join(dir, found);
-		}
-	}
-
-	return null;
-}
-
 /**
  * Load environment variables from process.env and .env files
  * @param {import('types').ValidatedKitConfig['env']} env_config
diff --git a/packages/kit/src/runtime/client/ambient.d.ts b/packages/kit/src/runtime/client/ambient.d.ts
index 4cf3c8f9a..b9240974d 100644
--- a/packages/kit/src/runtime/client/ambient.d.ts
+++ b/packages/kit/src/runtime/client/ambient.d.ts
@@ -1,5 +1,5 @@
 declare module '__GENERATED__/client-manifest.js' {
-	import { CSRPageNodeLoader, ParamMatcher } from 'types';
+	import { CSRPageNodeLoader, ClientHooks, ParamMatcher } from 'types';
 
 	/**
 	 * A list of all the error/layout/page nodes used in the app
@@ -21,4 +21,6 @@ declare module '__GENERATED__/client-manifest.js' {
 	export const dictionary: Record<string, [leaf: number, layouts: number[], errors?: number[]]>;
 
 	export const matchers: Record<string, ParamMatcher>;
+
+	export const hooks: ClientHooks;
 }
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 2659942c9..755f0d85c 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -4,10 +4,9 @@ import { make_trackable, decode_params, normalize_path } from '../../utils/url.j
 import { find_anchor, get_base_uri, scroll_state } from './utils.js';
 import { lock_fetch, unlock_fetch, initial_fetch, subsequent_fetch } from './fetcher.js';
 import { parse } from './parse.js';
-import { error } from '../../exports/index.js';
 
 import Root from '__GENERATED__/root.svelte';
-import { nodes, server_loads, dictionary, matchers } from '__GENERATED__/client-manifest.js';
+import { nodes, server_loads, dictionary, matchers, hooks } from '__GENERATED__/client-manifest.js';
 import { HttpError, Redirect } from '../control.js';
 import { stores } from './singletons.js';
 import { DATA_SUFFIX } from '../../constants.js';
@@ -386,7 +385,7 @@ export function create_client({ target, base, trailing_slash }) {
 	 *   params: Record<string, string>;
 	 *   branch: Array<import('./types').BranchNode | undefined>;
 	 *   status: number;
-	 *   error: HttpError | Error | null;
+	 *   error: App.PageError | null;
 	 *   route: import('types').CSRRoute | null;
 	 *   form?: Record<string, any> | null;
 	 * }} opts
@@ -756,12 +755,8 @@ export function create_client({ target, base, trailing_slash }) {
 			parent_changed = true;
 
 			if (server_data_node?.type === 'error') {
-				if (server_data_node.httperror) {
-					// reconstruct as an HttpError
-					throw error(server_data_node.httperror.status, server_data_node.httperror.message);
-				} else {
-					throw server_data_node.error;
-				}
+				// rethrow and catch below
+				throw server_data_node;
 			}
 
 			return load_node({
@@ -790,17 +785,29 @@ export function create_client({ target, base, trailing_slash }) {
 			if (loaders[i]) {
 				try {
 					branch.push(await branch_promises[i]);
-				} catch (e) {
-					const error = normalize_error(e);
-
-					if (error instanceof Redirect) {
+				} catch (err) {
+					if (err instanceof Redirect) {
 						return {
 							type: 'redirect',
-							location: error.location
+							location: err.location
 						};
 					}
 
-					const status = e instanceof HttpError ? e.status : 500;
+					let status = 500;
+					/** @type {App.PageError} */
+					let error;
+
+					if (server_data_nodes?.includes(/** @type {import('types').ServerErrorNode} */ (err))) {
+						// this is the server error rethrown above, reconstruct but don't invoke
+						// the client error handler; it should've already been handled on the server
+						status = /** @type {import('types').ServerErrorNode} */ (err).status ?? status;
+						error = /** @type {import('types').ServerErrorNode} */ (err).error;
+					} else if (err instanceof HttpError) {
+						status = err.status;
+						error = err.body;
+					} else {
+						error = handle_error(err, { params, url, routeId: route.id });
+					}
 
 					while (i--) {
 						if (errors[i]) {
@@ -920,7 +927,10 @@ export function create_client({ target, base, trailing_slash }) {
 			params,
 			branch: [root_layout, root_error],
 			status,
-			error,
+			error:
+				error instanceof HttpError
+					? error.body
+					: handle_error(error, { url, params, routeId: null }),
 			route: null
 		});
 	}
@@ -1395,7 +1405,7 @@ export function create_client({ target, base, trailing_slash }) {
 
 		_hydrate: async ({
 			status,
-			error: original_error, // TODO get rid of this
+			error,
 			node_ids,
 			params,
 			routeId,
@@ -1432,15 +1442,7 @@ export function create_client({ target, base, trailing_slash }) {
 					params,
 					branch: await Promise.all(branch_promises),
 					status,
-					error: /** @type {import('../server/page/types').SerializedHttpError} */ (original_error)
-						?.__is_http_error
-						? new HttpError(
-								/** @type {import('../server/page/types').SerializedHttpError} */ (
-									original_error
-								).status,
-								original_error.message
-						  )
-						: original_error,
+					error,
 					form,
 					route: routes.find((route) => route.id === routeId) ?? null
 				});
@@ -1498,6 +1500,15 @@ async function load_data(url, invalid) {
 	return server_data;
 }
 
+/**
+ * @param {unknown} error
+ * @param {import('types').NavigationEvent} event
+ * @returns {App.PageError}
+ */
+function handle_error(error, event) {
+	return hooks.handleError({ error, event }) ?? /** @type {any} */ ({ error: 'Internal Error' });
+}
+
 // TODO remove for 1.0
 const properties = [
 	'hash',
diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts
index 27818e66a..a566a4ba2 100644
--- a/packages/kit/src/runtime/client/types.d.ts
+++ b/packages/kit/src/runtime/client/types.d.ts
@@ -9,8 +9,6 @@ import {
 	prefetchRoutes
 } from '$app/navigation';
 import { CSRPageNode, CSRPageNodeLoader, CSRRoute, Uses } from 'types';
-import { HttpError } from '../control.js';
-import { SerializedHttpError } from '../server/page/types.js';
 
 export interface Client {
 	// public API, exposed via $app/navigation
@@ -27,7 +25,7 @@ export interface Client {
 	// private API
 	_hydrate: (opts: {
 		status: number;
-		error: Error | SerializedHttpError;
+		error: App.PageError;
 		node_ids: number[];
 		params: Record<string, string>;
 		routeId: string | null;
@@ -79,7 +77,7 @@ export interface DataNode {
 
 export interface NavigationState {
 	branch: Array<BranchNode | undefined>;
-	error: HttpError | Error | null;
+	error: App.PageError | null;
 	params: Record<string, string>;
 	route: CSRRoute | null;
 	session_id: number;
diff --git a/packages/kit/src/runtime/control.js b/packages/kit/src/runtime/control.js
index 680ed5d5c..ba2e9db90 100644
--- a/packages/kit/src/runtime/control.js
+++ b/packages/kit/src/runtime/control.js
@@ -1,23 +1,21 @@
 export class HttpError {
-	// without these, things like `$page.error.stack` will error. we don't want to
-	// include a stack for these sorts of errors, but we also don't want red
-	// squigglies everywhere, so this feels like a not-terribile compromise
-	name = 'HttpError';
-
-	/** @type {void} */
-	stack = undefined;
-
 	/**
 	 * @param {number} status
-	 * @param {string | undefined} message
+	 * @param {{message: string} extends App.PageError ? (App.PageError | string | undefined) : App.PageError} body
 	 */
-	constructor(status, message) {
+	constructor(status, body) {
 		this.status = status;
-		this.message = message ?? `Error: ${status}`;
+		if (typeof body === 'string') {
+			this.body = { message: body };
+		} else if (body) {
+			this.body = body;
+		} else {
+			this.body = { message: `Error: ${status}` };
+		}
 	}
 
 	toString() {
-		return this.message;
+		return JSON.stringify(this.body);
 	}
 }
 
diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js
index 25296344f..3f2c3654a 100644
--- a/packages/kit/src/runtime/server/data/index.js
+++ b/packages/kit/src/runtime/server/data/index.js
@@ -2,7 +2,7 @@ import { HttpError, Redirect } from '../../control.js';
 import { normalize_error } from '../../../utils/error.js';
 import { once } from '../../../utils/functions.js';
 import { load_server_data } from '../page/load_data.js';
-import { data_response, error_to_pojo } from '../utils.js';
+import { data_response, handle_error_and_jsonify } from '../utils.js';
 import { normalize_path } from '../../../utils/url.js';
 import { DATA_SUFFIX } from '../../../constants.js';
 
@@ -93,9 +93,7 @@ export async function render_data(event, route, options, state) {
 		let length = promises.length;
 		const nodes = await Promise.all(
 			promises.map((p, i) =>
-				p.catch((e) => {
-					const error = normalize_error(e);
-
+				p.catch((error) => {
 					if (error instanceof Redirect) {
 						throw error;
 					}
@@ -103,18 +101,10 @@ export async function render_data(event, route, options, state) {
 					// Math.min because array isn't guaranteed to resolve in order
 					length = Math.min(length, i + 1);
 
-					if (error instanceof HttpError) {
-						return /** @type {import('types').ServerErrorNode} */ ({
-							type: 'error',
-							httperror: { ...error }
-						});
-					}
-
-					options.handle_error(error, event);
-
 					return /** @type {import('types').ServerErrorNode} */ ({
 						type: 'error',
-						error: error_to_pojo(error, options.get_stack)
+						error: handle_error_and_jsonify(event, options, error),
+						status: error instanceof HttpError ? error.status : undefined
 					});
 				})
 			)
@@ -140,7 +130,7 @@ export async function render_data(event, route, options, state) {
 			return data_response(server_data);
 		} else {
 			// TODO make it clearer that this was an unexpected error
-			return data_response(error_to_pojo(error, options.get_stack));
+			return data_response(handle_error_and_jsonify(event, options, error));
 		}
 	}
 }
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index c9d2052f5..5511bcf00 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -10,6 +10,7 @@ import { exec } from '../../utils/routing.js';
 import { render_data } from './data/index.js';
 import { DATA_SUFFIX } from '../../constants.js';
 import { get_cookies } from './cookie.js';
+import { HttpError } from '../control.js';
 
 /* global __SVELTEKIT_ADAPTER_NAME__ */
 
@@ -309,7 +310,8 @@ export async function respond(request, options, state) {
 			// so we need to make an actual HTTP request
 			return await fetch(request);
 		} catch (e) {
-			const error = coalesce_to_error(e);
+			// HttpError can come from endpoint - TODO should it be handled there instead?
+			const error = e instanceof HttpError ? e : coalesce_to_error(e);
 			return handle_fatal_error(event, options, error);
 		} finally {
 			event.cookies.set = () => {
diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js
index c672a522e..6f9507e87 100644
--- a/packages/kit/src/runtime/server/page/actions.js
+++ b/packages/kit/src/runtime/server/page/actions.js
@@ -2,7 +2,7 @@ import { error, json } from '../../../exports/index.js';
 import { normalize_error } from '../../../utils/error.js';
 import { negotiate } from '../../../utils/http.js';
 import { HttpError, Redirect, ValidationError } from '../../control.js';
-import { error_to_pojo } from '../utils.js';
+import { handle_error_and_jsonify } from '../utils.js';
 
 /** @param {import('types').RequestEvent} event */
 export function is_action_json_request(event) {
@@ -69,7 +69,7 @@ export async function handle_action_json_request(event, options, server) {
 		return action_json(
 			{
 				type: 'error',
-				error: error_to_pojo(error, options.get_stack)
+				error: handle_error_and_jsonify(event, options, error)
 			},
 			{
 				status: error instanceof HttpError ? error.status : 500
diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js
index c85d233ea..64e617ee0 100644
--- a/packages/kit/src/runtime/server/page/index.js
+++ b/packages/kit/src/runtime/server/page/index.js
@@ -3,7 +3,12 @@ import { DATA_SUFFIX } from '../../../constants.js';
 import { compact } from '../../../utils/array.js';
 import { normalize_error } from '../../../utils/error.js';
 import { HttpError, Redirect } from '../../control.js';
-import { get_option, redirect_response, static_error_page } from '../utils.js';
+import {
+	get_option,
+	redirect_response,
+	static_error_page,
+	handle_error_and_jsonify
+} from '../utils.js';
 import {
 	handle_action_json_request,
 	handle_action_request,
@@ -197,13 +202,13 @@ export async function render_page(event, route, page, options, state, resolve_op
 
 					branch.push({ node, server_data, data });
 				} catch (e) {
-					const error = normalize_error(e);
+					const err = normalize_error(e);
 
-					if (error instanceof Redirect) {
+					if (err instanceof Redirect) {
 						if (state.prerendering && should_prerender_data) {
 							const body = `window.__sveltekit_data = ${JSON.stringify({
 								type: 'redirect',
-								location: error.location
+								location: err.location
 							})}`;
 
 							state.prerendering.dependencies.set(data_pathname, {
@@ -212,14 +217,11 @@ export async function render_page(event, route, page, options, state, resolve_op
 							});
 						}
 
-						return redirect_response(error.status, error.location);
-					}
-
-					if (!(error instanceof HttpError)) {
-						options.handle_error(/** @type {Error} */ (error), event);
+						return redirect_response(err.status, err.location);
 					}
 
-					const status = error instanceof HttpError ? error.status : 500;
+					const status = err instanceof HttpError ? err.status : 500;
+					const error = handle_error_and_jsonify(event, options, err);
 
 					while (i--) {
 						if (page.errors[i]) {
@@ -250,11 +252,7 @@ export async function render_page(event, route, page, options, state, resolve_op
 
 					// if we're still here, it means the error happened in the root layout,
 					// which means we have to fall back to error.html
-					return static_error_page(
-						options,
-						status,
-						/** @type {HttpError | Error} */ (error).message
-					);
+					return static_error_page(options, status, error.message);
 				}
 			} else {
 				// push an empty slot so we can rewind past gaps to the
@@ -294,14 +292,12 @@ export async function render_page(event, route, page, options, state, resolve_op
 	} catch (error) {
 		// if we end up here, it means the data loaded successfull
 		// but the page failed to render, or that a prerendering error occurred
-		options.handle_error(/** @type {Error} */ (error), event);
-
 		return await respond_with_error({
 			event,
 			options,
 			state,
 			status: 500,
-			error: /** @type {Error} */ (error),
+			error,
 			resolve_opts
 		});
 	}
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 2fb4ea7ba..5480db4ee 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -5,8 +5,6 @@ import { hash } from '../../hash.js';
 import { serialize_data } from './serialize_data.js';
 import { s } from '../../../utils/misc.js';
 import { Csp } from './csp.js';
-import { serialize_error } from '../utils.js';
-import { HttpError } from '../../control.js';
 
 // TODO rename this function/module
 
@@ -25,7 +23,7 @@ const updated = {
  *   state: import('types').SSRState;
  *   page_config: { ssr: boolean; csr: boolean };
  *   status: number;
- *   error: HttpError | Error | null;
+ *   error: App.PageError | null;
  *   event: import('types').RequestEvent;
  *   resolve_opts: import('types').RequiredResolveOptions;
  *   action_result?: import('types').ActionResult;
@@ -68,12 +66,6 @@ export async function render_response({
 
 	let rendered;
 
-	const stack = error instanceof HttpError ? undefined : error?.stack;
-
-	if (error && options.dev && !(error instanceof HttpError)) {
-		error.stack = options.get_stack(error);
-	}
-
 	const form_value =
 		action_result?.type === 'success' || action_result?.type === 'invalid'
 			? action_result.data ?? null
@@ -203,7 +195,7 @@ export async function render_response({
 			env: ${s(options.public_env)},
 			hydrate: ${page_config.ssr ? `{
 				status: ${status},
-				error: ${error && serialize_error(error, e => e.stack)},
+				error: ${s(error)},
 				node_ids: [${branch.map(({ node }) => node.index).join(', ')}],
 				params: ${devalue(event.params)},
 				routeId: ${s(event.routeId)},
@@ -347,11 +339,6 @@ export async function render_response({
 		}
 	}
 
-	if (error && options.dev && !(error instanceof HttpError)) {
-		// reset stack, otherwise it may be 'fixed' a second time
-		error.stack = stack;
-	}
-
 	return new Response(html, {
 		status,
 		headers
diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js
index d3ce83ffd..b0e790f02 100644
--- a/packages/kit/src/runtime/server/page/respond_with_error.js
+++ b/packages/kit/src/runtime/server/page/respond_with_error.js
@@ -1,7 +1,11 @@
 import { render_response } from './render.js';
 import { load_data, load_server_data } from './load_data.js';
-import { coalesce_to_error } from '../../../utils/error.js';
-import { GENERIC_ERROR, get_option, static_error_page } from '../utils.js';
+import {
+	handle_error_and_jsonify,
+	GENERIC_ERROR,
+	get_option,
+	static_error_page
+} from '../utils.js';
 import { create_fetch } from './fetch.js';
 
 /**
@@ -16,7 +20,7 @@ import { create_fetch } from './fetch.js';
  *   options: SSROptions;
  *   state: SSRState;
  *   status: number;
- *   error: Error;
+ *   error: unknown;
  *   resolve_opts: import('types').RequiredResolveOptions;
  * }} opts
  */
@@ -75,18 +79,14 @@ export async function respond_with_error({ event, options, state, status, error,
 				csr: get_option([default_layout], 'csr') ?? true
 			},
 			status,
-			error,
+			error: handle_error_and_jsonify(event, options, error),
 			branch,
 			fetched,
 			cookies,
 			event,
 			resolve_opts
 		});
-	} catch (err) {
-		const error = coalesce_to_error(err);
-
-		options.handle_error(error, event);
-
-		return static_error_page(options, 500, error.message);
+	} catch (error) {
+		return static_error_page(options, 500, handle_error_and_jsonify(event, options, error).message);
 	}
 }
diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts
index 648baaf7b..7416f82ae 100644
--- a/packages/kit/src/runtime/server/page/types.d.ts
+++ b/packages/kit/src/runtime/server/page/types.d.ts
@@ -33,9 +33,3 @@ export interface CspOpts {
 	dev: boolean;
 	prerender: boolean;
 }
-
-export interface SerializedHttpError extends Pick<HttpError, 'message' | 'status'> {
-	name: 'HttpError';
-	stack: '';
-	__is_http_error: true;
-}
diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js
index 0c1b662aa..025823262 100644
--- a/packages/kit/src/runtime/server/utils.js
+++ b/packages/kit/src/runtime/server/utils.js
@@ -21,57 +21,6 @@ export function is_pojo(body) {
 	return true;
 }
 
-/**
- * Serialize an error into a JSON string through `error_to_pojo`.
- * This is necessary because `JSON.stringify(error) === '{}'`
- *
- * @param {Error | HttpError} error
- * @param {(error: Error) => string | undefined} get_stack
- */
-export function serialize_error(error, get_stack) {
-	return JSON.stringify(error_to_pojo(error, get_stack));
-}
-
-/**
- * Transform an error into a POJO, by copying its `name`, `message`
- * and (in dev) `stack`, plus any custom properties, plus recursively
- * serialized `cause` properties.
- * Our own HttpError gets a meta property attached so we can identify it on the client.
- *
- * @param {HttpError | Error } error
- * @param {(error: Error) => string | undefined} get_stack
- */
-export function error_to_pojo(error, get_stack) {
-	if (error instanceof HttpError) {
-		return /** @type {import('./page/types').SerializedHttpError} */ ({
-			message: error.message,
-			status: error.status,
-			__is_http_error: true // TODO we should probably make this unnecessary
-		});
-	}
-
-	const {
-		name,
-		message,
-		stack,
-		// @ts-expect-error i guess typescript doesn't know about error.cause yet
-		cause,
-		...custom
-	} = error;
-
-	/** @type {Record<string, any>} */
-	const object = { name, message, stack: get_stack(error) };
-
-	if (cause) object.cause = error_to_pojo(cause, get_stack);
-
-	for (const key in custom) {
-		// @ts-expect-error
-		object[key] = custom[key];
-	}
-
-	return object;
-}
-
 // TODO: Remove for 1.0
 /** @param {Record<string, any>} mod */
 export function check_method_names(mod) {
@@ -181,16 +130,11 @@ export function static_error_page(options, status, message) {
 /**
  * @param {import('types').RequestEvent} event
  * @param {import('types').SSROptions} options
- * @param {Error} error
+ * @param {Error | HttpError} error
  */
 export function handle_fatal_error(event, options, error) {
-	let status = 500;
-
-	if (error instanceof HttpError) {
-		status = error.status;
-	} else {
-		options.handle_error(error, event);
-	}
+	const status = error instanceof HttpError ? error.status : 500;
+	const body = handle_error_and_jsonify(event, options, error);
 
 	// ideally we'd use sec-fetch-dest instead, but Safari — quelle surprise — doesn't support it
 	const type = negotiate(event.request.headers.get('accept') || 'text/html', [
@@ -199,13 +143,27 @@ export function handle_fatal_error(event, options, error) {
 	]);
 
 	if (event.url.pathname.endsWith(DATA_SUFFIX) || type === 'application/json') {
-		return new Response(serialize_error(error, options.get_stack), {
+		return new Response(JSON.stringify(body), {
 			status,
 			headers: { 'content-type': 'application/json; charset=utf-8' }
 		});
 	}
 
-	return static_error_page(options, status, error.message);
+	return static_error_page(options, status, body.message);
+}
+
+/**
+ * @param {import('types').RequestEvent} event
+ * @param {import('types').SSROptions} options
+ * @param {any} error
+ * @returns {App.PageError}
+ */
+export function handle_error_and_jsonify(event, options, error) {
+	if (error instanceof HttpError) {
+		return error.body;
+	} else {
+		return options.handle_error(error, event);
+	}
 }
 
 /**
diff --git a/packages/kit/src/runtime/server/utils.spec.js b/packages/kit/src/runtime/server/utils.spec.js
deleted file mode 100644
index 8ec55eefe..000000000
--- a/packages/kit/src/runtime/server/utils.spec.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { test } from 'uvu';
-import * as assert from 'uvu/assert';
-import { serialize_error } from './utils.js';
-
-test('serialize_error', () => {
-	class FancyError extends Error {
-		name = 'FancyError';
-		fancy = true;
-
-		/**
-		 * @param {string} message
-		 * @param {{
-		 *   cause?: Error
-		 * }} [options]
-		 */
-		constructor(message, options) {
-			// @ts-expect-error go home typescript ur drunk
-			super(message, options);
-		}
-	}
-
-	const error = new FancyError('something went wrong', {
-		cause: new Error('sorry')
-	});
-
-	const serialized = serialize_error(error, (error) => error.stack);
-
-	assert.equal(
-		serialized,
-		JSON.stringify({
-			name: 'FancyError',
-			message: 'something went wrong',
-			stack: error.stack,
-			cause: {
-				name: 'Error',
-				message: 'sorry',
-				// @ts-expect-error
-				stack: error.cause.stack
-			},
-			fancy: true
-		})
-	);
-});
-
-test.run();
diff --git a/packages/kit/src/utils/filesystem.js b/packages/kit/src/utils/filesystem.js
index 5b907ca09..8ddcc0c5c 100644
--- a/packages/kit/src/utils/filesystem.js
+++ b/packages/kit/src/utils/filesystem.js
@@ -106,3 +106,32 @@ export function walk(cwd, dirs = false) {
 export function posixify(str) {
 	return str.replace(/\\/g, '/');
 }
+
+/**
+ * Given an entry point like [cwd]/src/hooks, returns a filename like [cwd]/src/hooks.js or [cwd]/src/hooks/index.js
+ * @param {string} entry
+ * @returns {string|null}
+ */
+export function resolve_entry(entry) {
+	if (fs.existsSync(entry)) {
+		const stats = fs.statSync(entry);
+		if (stats.isDirectory()) {
+			return resolve_entry(path.join(entry, 'index'));
+		}
+
+		return entry;
+	} else {
+		const dir = path.dirname(entry);
+
+		if (fs.existsSync(dir)) {
+			const base = path.basename(entry);
+			const files = fs.readdirSync(dir);
+
+			const found = files.find((file) => file.replace(/\.[^.]+$/, '') === base);
+
+			if (found) return path.join(dir, found);
+		}
+	}
+
+	return null;
+}
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index 0e49f130c..e3ce20d11 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -56,6 +56,13 @@ declare namespace App {
 	 * If your adapter provides [platform-specific context](https://kit.svelte.dev/docs/adapters#supported-environments-platform-specific-context) via `event.platform`, you can specify it here.
 	 */
 	export interface Platform {}
+
+	/**
+	 * Defines the common shape of expected and unexpected errors. Expected errors are thrown using the `error` function. Unexpected errors are handled by the `handleError` hooks which should return this shape.
+	 */
+	export interface PageError {
+		message: string;
+	}
 }
 
 /**
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index 1d63adbc4..6b3d40651 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -158,7 +158,10 @@ export interface KitConfig {
 	moduleExtensions?: string[];
 	files?: {
 		assets?: string;
-		hooks?: string;
+		hooks?: {
+			client?: string;
+			server?: string;
+		};
 		lib?: string;
 		params?: string;
 		routes?: string;
@@ -199,8 +202,12 @@ export interface Handle {
 	}): MaybePromise<Response>;
 }
 
-export interface HandleError {
-	(input: { error: Error & { frame?: string }; event: RequestEvent }): void;
+export interface HandleServerError {
+	(input: { error: unknown; event: RequestEvent }): void | App.PageError;
+}
+
+export interface HandleClientError {
+	(input: { error: unknown; event: NavigationEvent }): void | App.PageError;
 }
 
 export interface HandleFetch {
@@ -224,17 +231,22 @@ export interface LoadEvent<
 	Params extends Partial<Record<string, string>> = Partial<Record<string, string>>,
 	Data extends Record<string, unknown> | null = Record<string, any> | null,
 	ParentData extends Record<string, unknown> = Record<string, any>
-> {
+> extends NavigationEvent<Params> {
 	fetch(info: RequestInfo, init?: RequestInit): Promise<Response>;
-	params: Params;
 	data: Data;
-	routeId: string | null;
 	setHeaders: (headers: Record<string, string>) => void;
-	url: URL;
 	parent: () => Promise<ParentData>;
 	depends: (...deps: string[]) => void;
 }
 
+export interface NavigationEvent<
+	Params extends Partial<Record<string, string>> = Partial<Record<string, string>>
+> {
+	params: Params;
+	routeId: string | null;
+	url: URL;
+}
+
 export interface NavigationTarget {
 	params: Record<string, string> | null;
 	routeId: string | null;
@@ -255,7 +267,7 @@ export interface Page<Params extends Record<string, string> = Record<string, str
 	params: Params;
 	routeId: string | null;
 	status: number;
-	error: HttpError | Error | null;
+	error: App.PageError | null;
 	data: App.PageData & Record<string, any>;
 }
 
@@ -364,17 +376,19 @@ export type ActionResult<
 	| { type: 'redirect'; status: number; location: string }
 	| { type: 'error'; error: any };
 
-// TODO figure out how to just re-export from '../src/index/index.js' without
-// breaking the site
-
 /**
  * Creates an `HttpError` object with an HTTP status code and an optional message.
  * This object, if thrown during request handling, will cause SvelteKit to
  * return an error response without invoking `handleError`
- * @param {number} status
- * @param {string | undefined} [message]
+ * @param status The HTTP status code
+ * @param body An object that conforms to the App.PageError type. If a string is passed, it will be used as the message property.
  */
-export function error(status: number, message?: string | undefined): HttpError;
+export function error(status: number, body: App.PageError): HttpError;
+export function error(
+	status: number,
+	// this overload ensures you can omit the argument or pass in a string if App.PageError is of type { message: string }
+	body?: { message: string } extends App.PageError ? App.PageError | string | undefined : never
+): HttpError;
 
 /**
  * Creates a `Redirect` object. If thrown during request handling, SvelteKit will
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 44d494d4c..0e72dda4b 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -4,7 +4,7 @@ import {
 	Config,
 	ServerLoad,
 	Handle,
-	HandleError,
+	HandleServerError,
 	KitConfig,
 	Load,
 	RequestEvent,
@@ -14,7 +14,8 @@ import {
 	ServerInitOptions,
 	SSRManifest,
 	HandleFetch,
-	Actions
+	Actions,
+	HandleClientError
 } from './index.js';
 import {
 	HttpMethod,
@@ -89,10 +90,14 @@ export type CSRRoute = {
 
 export type GetParams = (match: RegExpExecArray) => Record<string, string>;
 
-export interface Hooks {
-	handle: Handle;
-	handleError: HandleError;
+export interface ServerHooks {
 	handleFetch: HandleFetch;
+	handle: Handle;
+	handleError: HandleServerError;
+}
+
+export interface ClientHooks {
+	handleError: HandleClientError;
 }
 
 export interface ImportNode {
@@ -227,9 +232,11 @@ export interface ServerDataSkippedNode {
  */
 export interface ServerErrorNode {
 	type: 'error';
-	// Either-or situation, but we don't want to have to do a type assertion
-	error?: Record<string, any>;
-	httperror?: { status: number; message: string };
+	error: App.PageError;
+	/**
+	 * Only set for HttpErrors
+	 */
+	status?: number;
 }
 
 export interface SSRComponent {
@@ -287,9 +294,8 @@ export interface SSROptions {
 		check_origin: boolean;
 	};
 	dev: boolean;
-	get_stack: (error: Error) => string | undefined;
-	handle_error(error: Error & { frame?: string }, event: RequestEvent): void;
-	hooks: Hooks;
+	handle_error(error: Error & { frame?: string }, event: RequestEvent): App.PageError;
+	hooks: ServerHooks;
 	manifest: SSRManifest;
 	paths: {
 		base: string;


Test patch:
diff --git a/packages/kit/test/apps/amp/src/hooks.js b/packages/kit/test/apps/amp/src/hooks.server.js
similarity index 100%
rename from packages/kit/test/apps/amp/src/hooks.js
rename to packages/kit/test/apps/amp/src/hooks.server.js
diff --git a/packages/kit/test/apps/basics/src/hooks.client.js b/packages/kit/test/apps/basics/src/hooks.client.js
new file mode 100644
index 000000000..62ee8c1eb
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/hooks.client.js
@@ -0,0 +1,4 @@
+/** @type{import("@sveltejs/kit").HandleClientError} */
+export function handleError({ error }) {
+	return { message: /** @type {Error} */ (error).message };
+}
diff --git a/packages/kit/test/apps/basics/src/hooks.js b/packages/kit/test/apps/basics/src/hooks.server.js
similarity index 69%
rename from packages/kit/test/apps/basics/src/hooks.js
rename to packages/kit/test/apps/basics/src/hooks.server.js
index 71885efa2..f722b32b0 100644
--- a/packages/kit/test/apps/basics/src/hooks.js
+++ b/packages/kit/test/apps/basics/src/hooks.server.js
@@ -1,8 +1,29 @@
 import fs from 'fs';
 import { sequence } from '@sveltejs/kit/hooks';
+import { HttpError } from '../../../../src/runtime/control';
 
-/** @type {import('@sveltejs/kit').HandleError} */
-export const handleError = ({ event, error }) => {
+/**
+ * Transform an error into a POJO, by copying its `name`, `message`
+ * and (in dev) `stack`, plus any custom properties, plus recursively
+ * serialized `cause` properties.
+ *
+ * @param {HttpError | Error } error
+ */
+export function error_to_pojo(error) {
+	if (error instanceof HttpError) {
+		return {
+			status: error.status,
+			...error.body
+		};
+	}
+
+	const { name, message, stack, cause, ...custom } = error;
+	return { name, message, stack, ...custom };
+}
+
+/** @type {import('@sveltejs/kit').HandleServerError} */
+export const handleError = ({ event, error: e }) => {
+	const error = /** @type {Error} */ (e);
 	// TODO we do this because there's no other way (that i'm aware of)
 	// to communicate errors back to the test suite. even if we could
 	// capture stderr, attributing an error to a specific request
@@ -10,8 +31,9 @@ export const handleError = ({ event, error }) => {
 	const errors = fs.existsSync('test/errors.json')
 		? JSON.parse(fs.readFileSync('test/errors.json', 'utf8'))
 		: {};
-	errors[event.url.pathname] = error.stack || error.message;
+	errors[event.url.pathname] = error_to_pojo(error);
 	fs.writeFileSync('test/errors.json', JSON.stringify(errors));
+	return { message: error.message };
 };
 
 export const handle = sequence(
diff --git a/packages/kit/test/apps/basics/src/routes/+error.svelte b/packages/kit/test/apps/basics/src/routes/+error.svelte
index d1897f37b..476d730cd 100644
--- a/packages/kit/test/apps/basics/src/routes/+error.svelte
+++ b/packages/kit/test/apps/basics/src/routes/+error.svelte
@@ -10,8 +10,6 @@
 
 <p id="message">This is your custom error page saying: "<b>{$page.error.message}</b>"</p>
 
-<pre id="stack">{$page.error.stack}</pre>
-
 <style>
 	h1,
 	p {
diff --git a/packages/kit/test/apps/basics/src/routes/errors/page-endpoint/+error.svelte b/packages/kit/test/apps/basics/src/routes/errors/page-endpoint/+error.svelte
index 5fdd3f068..21571474a 100644
--- a/packages/kit/test/apps/basics/src/routes/errors/page-endpoint/+error.svelte
+++ b/packages/kit/test/apps/basics/src/routes/errors/page-endpoint/+error.svelte
@@ -5,11 +5,7 @@
 <pre>{JSON.stringify(
 		{
 			status: $page.status,
-			name: $page.error.name,
-			message: $page.error.message,
-			stack: $page.error.stack,
-			// @ts-expect-error
-			fancy: $page.error.fancy
+			...$page.error
 		},
 		null,
 		'  '
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index d5c25e9a5..0404859e5 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -190,11 +190,7 @@ test.describe('Errors', () => {
 
 			expect(res.status()).toBe(401);
 			expect(await res.json()).toEqual({
-				status: 401,
-				message: 'You shall not pass',
-
-				// TODO this is gross, fix it
-				__is_http_error: true
+				message: 'You shall not pass'
 			});
 		}
 	});
@@ -232,12 +228,9 @@ test.describe('Errors', () => {
 
 			const error = await res.json();
 
-			expect(typeof error.stack).toBe('string');
-			delete error.stack;
-
+			expect(error.stack).toBe(undefined);
 			expect(res.status()).toBe(500);
 			expect(error).toEqual({
-				name: 'Error',
 				message: 'Error in handle'
 			});
 		}
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index e8dd4bdbf..e9161307f 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -496,7 +496,7 @@ test.describe('Errors', () => {
 		const res = await page.goto('/errors/endpoint');
 
 		// should include stack trace
-		const lines = read_errors('/errors/endpoint.json').split('\n');
+		const lines = read_errors('/errors/endpoint.json').stack.split('\n');
 		expect(lines[0]).toMatch('nope');
 
 		if (process.env.DEV) {
@@ -505,42 +505,23 @@ test.describe('Errors', () => {
 
 		expect(res && res.status()).toBe(500);
 		expect(await page.textContent('#message')).toBe('This is your custom error page saying: "500"');
-
-		const contents = await page.textContent('#stack');
-		const location = '+page.js:7:9';
-
-		if (process.env.DEV) {
-			expect(contents).toMatch(location);
-		} else {
-			expect(contents).not.toMatch(location);
-		}
 	});
 
 	test('error in shadow endpoint', async ({ page, read_errors }) => {
-		const location = '+page.server.js:3:8';
-
 		const res = await page.goto('/errors/endpoint-shadow');
 
 		// should include stack trace
-		const lines = read_errors('/errors/endpoint-shadow').split('\n');
+		const lines = read_errors('/errors/endpoint-shadow').stack.split('\n');
 		expect(lines[0]).toMatch('nope');
 
 		if (process.env.DEV) {
-			expect(lines[1]).toMatch(location);
+			expect(lines[1]).toMatch('+page.server.js:3:8');
 		}
 
 		expect(res && res.status()).toBe(500);
 		expect(await page.textContent('#message')).toBe(
 			'This is your custom error page saying: "nope"'
 		);
-
-		const contents = await page.textContent('#stack');
-
-		if (process.env.DEV) {
-			expect(contents).toMatch(location);
-		} else {
-			expect(contents).not.toMatch(location);
-		}
 	});
 
 	test('not ok response from shadow endpoint', async ({ page, read_errors }) => {
@@ -572,22 +553,22 @@ test.describe('Errors', () => {
 	}) => {
 		await page.goto('/errors/page-endpoint');
 		await clicknav('#get-implicit');
-		const json = await page.textContent('pre');
-		if (!json) throw new Error('Could not extract content from element');
-		const { status, name, message, stack, fancy } = JSON.parse(json);
 
-		expect(status).toBe(500);
+		expect(await page.textContent('pre')).toBe(
+			JSON.stringify({ status: 500, message: 'oops' }, null, '  ')
+		);
+
+		const { status, name, message, stack, fancy } = read_errors(
+			'/errors/page-endpoint/get-implicit/__data.js'
+		);
+		expect(status).toBe(undefined);
 		expect(name).toBe('FancyError');
 		expect(message).toBe('oops');
 		expect(fancy).toBe(true);
-
 		if (process.env.DEV) {
 			const lines = stack.split('\n');
 			expect(lines[1]).toContain('+page.server.js:4:8');
 		}
-
-		const error = read_errors('/errors/page-endpoint/get-implicit/__data.js');
-		expect(error).toContain('oops');
 	});
 
 	test('page endpoint GET HttpError message is preserved', async ({
@@ -597,13 +578,10 @@ test.describe('Errors', () => {
 	}) => {
 		await page.goto('/errors/page-endpoint');
 		await clicknav('#get-explicit');
-		const json = await page.textContent('pre');
-		if (!json) throw new Error('Could not extract content from element');
-		const { status, message, stack } = JSON.parse(json);
 
-		expect(status).toBe(400);
-		expect(message).toBe('oops');
-		expect(stack).toBeUndefined();
+		expect(await page.textContent('pre')).toBe(
+			JSON.stringify({ status: 400, message: 'oops' }, null, '  ')
+		);
 
 		const error = read_errors('/errors/page-endpoint/get-explicit');
 		expect(error).toBe(undefined);
@@ -617,22 +595,23 @@ test.describe('Errors', () => {
 		// It should show the __error template with our message.
 		await page.goto('/errors/page-endpoint');
 		await Promise.all([page.waitForNavigation(), page.click('#post-implicit')]);
-		const json = await page.textContent('pre');
-		if (!json) throw new Error('Could not extract content from element');
-		const { status, name, message, stack, fancy } = JSON.parse(json);
 
-		expect(status).toBe(500);
+		expect(await page.textContent('pre')).toBe(
+			JSON.stringify({ status: 500, message: 'oops' }, null, '  ')
+		);
+
+		const { status, name, message, stack, fancy } = read_errors(
+			'/errors/page-endpoint/post-implicit'
+		);
+
+		expect(status).toBe(undefined);
 		expect(name).toBe('FancyError');
 		expect(message).toBe('oops');
 		expect(fancy).toBe(true);
-
 		if (process.env.DEV) {
 			const lines = stack.split('\n');
 			expect(lines[1]).toContain('+page.server.js:6:9');
 		}
-
-		const error = read_errors('/errors/page-endpoint/post-implicit');
-		expect(error).toContain('oops');
 	});
 
 	test('page endpoint POST HttpError error message is preserved', async ({ page, read_errors }) => {
@@ -640,13 +619,10 @@ test.describe('Errors', () => {
 		// It should show the __error template with our message.
 		await page.goto('/errors/page-endpoint');
 		await Promise.all([page.waitForNavigation(), page.click('#post-explicit')]);
-		const json = await page.textContent('pre');
-		if (!json) throw new Error('Could not extract content from element');
-		const { status, message, stack } = JSON.parse(json);
 
-		expect(status).toBe(400);
-		expect(message).toBe('oops');
-		expect(stack).toBeUndefined();
+		expect(await page.textContent('pre')).toBe(
+			JSON.stringify({ status: 400, message: 'oops' }, null, '  ')
+		);
 
 		const error = read_errors('/errors/page-endpoint/post-explicit');
 		expect(error).toBe(undefined);
@@ -911,8 +887,8 @@ test.describe('Load', () => {
 
 		await page.goto(`/load/cors?port=${port}`);
 		expect(await page.textContent('h1')).toBe('500');
-		expect(read_errors(`/load/cors`)).toContain(
-			`Error: CORS error: No 'Access-Control-Allow-Origin' header is present on the requested resource`
+		expect(read_errors(`/load/cors`).message).toContain(
+			`CORS error: No 'Access-Control-Allow-Origin' header is present on the requested resource`
 		);
 
 		await page.goto(`/load/cors/no-cors?port=${port}`);
@@ -1265,7 +1241,7 @@ test.describe('Redirects', () => {
 
 		if (!javaScriptEnabled) {
 			// handleError is not invoked for client-side navigation
-			const lines = read_errors('/redirect/missing-status/a').split('\n');
+			const lines = read_errors('/redirect/missing-status/a').stack.split('\n');
 			expect(lines[0]).toBe('Error: Invalid status code');
 		}
 	});
diff --git a/packages/kit/test/apps/dev-only/src/hooks.server.js b/packages/kit/test/apps/dev-only/src/hooks.server.js
new file mode 100644
index 000000000..b9bd53a81
--- /dev/null
+++ b/packages/kit/test/apps/dev-only/src/hooks.server.js
@@ -0,0 +1,4 @@
+/** @type {import("@sveltejs/kit").HandleServerError} */
+export function handleError({ error }) {
+	return { message: /**@type{any}*/ (error).message };
+}
diff --git a/packages/kit/test/apps/dev-only/src/routes/+error.svelte b/packages/kit/test/apps/dev-only/src/routes/+error.svelte
index e0fb71781..830415220 100644
--- a/packages/kit/test/apps/dev-only/src/routes/+error.svelte
+++ b/packages/kit/test/apps/dev-only/src/routes/+error.svelte
@@ -2,4 +2,4 @@
 	import { page } from '$app/stores';
 </script>
 
-<pre>{$page.error}</pre>
+<pre>{$page.error.message}</pre>
diff --git a/packages/kit/test/apps/dev-only/test/test.js b/packages/kit/test/apps/dev-only/test/test.js
index dadfce485..2fadb64ea 100644
--- a/packages/kit/test/apps/dev-only/test/test.js
+++ b/packages/kit/test/apps/dev-only/test/test.js
@@ -9,7 +9,7 @@ test.describe('$env', () => {
 	test('$env/dynamic/private is not statically importable from the client', async ({ request }) => {
 		const resp = await request.get('/env/dynamic-private');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$env\/dynamic\/private into client-side code:.*/gs
+			/.*Cannot import \$env\/dynamic\/private into client-side code:.*/gs
 		);
 	});
 
@@ -18,21 +18,21 @@ test.describe('$env', () => {
 	}) => {
 		const resp = await request.get('/env/dynamic-private-dynamic-import');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$env\/dynamic\/private into client-side code:.*/gs
+			/.*Cannot import \$env\/dynamic\/private into client-side code:.*/gs
 		);
 	});
 
 	test('$env/static/private is not statically importable from the client', async ({ request }) => {
 		const resp = await request.get('/env/static-private');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$env\/static\/private into client-side code:.*/gs
+			/.*Cannot import \$env\/static\/private into client-side code:.*/gs
 		);
 	});
 
 	test('$env/static/private is not dynamically importable from the client', async ({ request }) => {
 		const resp = await request.get('/env/static-private-dynamic-import');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$env\/static\/private into client-side code:.*/gs
+			/.*Cannot import \$env\/static\/private into client-side code:.*/gs
 		);
 	});
 });
@@ -41,13 +41,13 @@ test.describe('server-only modules', () => {
 	test('server-only module is not statically importable from the client', async ({ request }) => {
 		const resp = await request.get('/server-only-modules/static-import');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$lib\/test.server.js into client-side code:.*/gs
+			/.*Cannot import \$lib\/test.server.js into client-side code:.*/gs
 		);
 	});
 	test('server-only module is not dynamically importable from the client', async ({ request }) => {
 		const resp = await request.get('/server-only-modules/dynamic-import');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$lib\/test.server.js into client-side code:.*/gs
+			/.*Cannot import \$lib\/test.server.js into client-side code:.*/gs
 		);
 	});
 });
@@ -56,13 +56,13 @@ test.describe('server-only folder', () => {
 	test('server-only folder is not statically importable from the client', async ({ request }) => {
 		const resp = await request.get('/server-only-folder/static-import');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$lib\/server\/blah\/test.js into client-side code:.*/gs
+			/.*Cannot import \$lib\/server\/blah\/test.js into client-side code:.*/gs
 		);
 	});
 	test('server-only folder is not dynamically importable from the client', async ({ request }) => {
 		const resp = await request.get('/server-only-folder/dynamic-import');
 		expect(await resp.text()).toMatch(
-			/.*Error: Cannot import \$lib\/server\/blah\/test.js into client-side code:.*/gs
+			/.*Cannot import \$lib\/server\/blah\/test.js into client-side code:.*/gs
 		);
 	});
 });
diff --git a/packages/kit/test/apps/options/source/hooks.js b/packages/kit/test/apps/options/source/hooks.client.js
similarity index 100%
rename from packages/kit/test/apps/options/source/hooks.js
rename to packages/kit/test/apps/options/source/hooks.client.js
diff --git a/packages/kit/test/prerendering/options/src/hooks.js b/packages/kit/test/apps/options/source/hooks.server.js
similarity index 100%
rename from packages/kit/test/prerendering/options/src/hooks.js
rename to packages/kit/test/apps/options/source/hooks.server.js
diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js
index 5bef3229b..2246d8b5b 100644
--- a/packages/kit/test/apps/options/svelte.config.js
+++ b/packages/kit/test/apps/options/svelte.config.js
@@ -12,6 +12,10 @@ const config = {
 			lib: 'source/components',
 			routes: 'source/pages',
 			appTemplate: 'source/template.html',
+			hooks: {
+				client: 'source/hooks.client.js',
+				server: 'source/hooks.server.js'
+			},
 			// while we specify a path for the service worker, we expect it to not exist in the test
 			serviceWorker: 'source/service-worker'
 		},
diff --git a/packages/kit/test/build-errors/env.spec.js b/packages/kit/test/build-errors/env.spec.js
index e6a6ee3a2..c30a19bfc 100644
--- a/packages/kit/test/build-errors/env.spec.js
+++ b/packages/kit/test/build-errors/env.spec.js
@@ -11,7 +11,7 @@ test('$env/dynamic/private is not statically importable from the client', () =>
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$env\/dynamic\/private into client-side code:.*/gs
+		/.*Cannot import \$env\/dynamic\/private into client-side code:.*/gs
 	);
 });
 
@@ -23,7 +23,7 @@ test('$env/dynamic/private is not dynamically importable from the client', () =>
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$env\/dynamic\/private into client-side code:.*/gs
+		/.*Cannot import \$env\/dynamic\/private into client-side code:.*/gs
 	);
 });
 
@@ -35,7 +35,7 @@ test('$env/static/private is not statically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$env\/static\/private into client-side code:.*/gs
+		/.*Cannot import \$env\/static\/private into client-side code:.*/gs
 	);
 });
 
@@ -47,7 +47,7 @@ test('$env/static/private is not dynamically importable from the client', () =>
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$env\/static\/private into client-side code:.*/gs
+		/.*Cannot import \$env\/static\/private into client-side code:.*/gs
 	);
 });
 
diff --git a/packages/kit/test/build-errors/server-only.spec.js b/packages/kit/test/build-errors/server-only.spec.js
index a6ede127c..7e620820e 100644
--- a/packages/kit/test/build-errors/server-only.spec.js
+++ b/packages/kit/test/build-errors/server-only.spec.js
@@ -11,7 +11,7 @@ test('$lib/*.server.* is not statically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$lib\/test.server.js into client-side code:.*/gs
+		/.*Cannot import \$lib\/test.server.js into client-side code:.*/gs
 	);
 });
 
@@ -23,7 +23,7 @@ test('$lib/*.server.* is not dynamically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$lib\/test.server.js into client-side code:.*/gs
+		/.*Cannot import \$lib\/test.server.js into client-side code:.*/gs
 	);
 });
 
@@ -35,7 +35,7 @@ test('$lib/server/* is not statically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$lib\/server\/something\/test.js into client-side code:.*/gs
+		/.*Cannot import \$lib\/server\/something\/test.js into client-side code:.*/gs
 	);
 });
 
@@ -47,7 +47,7 @@ test('$lib/server/* is not dynamically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Error: Cannot import \$lib\/server\/something\/test.js into client-side code:.*/gs
+		/.*Cannot import \$lib\/server\/something\/test.js into client-side code:.*/gs
 	);
 });
 
diff --git a/packages/kit/test/prerendering/basics/src/hooks.js b/packages/kit/test/prerendering/basics/src/hooks.server.js
similarity index 100%
rename from packages/kit/test/prerendering/basics/src/hooks.js
rename to packages/kit/test/prerendering/basics/src/hooks.server.js
diff --git a/packages/kit/test/prerendering/fallback/src/hooks.js b/packages/kit/test/prerendering/fallback/src/hooks.server.js
similarity index 100%
rename from packages/kit/test/prerendering/fallback/src/hooks.js
rename to packages/kit/test/prerendering/fallback/src/hooks.server.js
diff --git a/packages/kit/test/prerendering/paths-base/src/hooks.js b/packages/kit/test/prerendering/options/src/hooks.server.js
similarity index 100%
rename from packages/kit/test/prerendering/paths-base/src/hooks.js
rename to packages/kit/test/prerendering/options/src/hooks.server.js
diff --git a/packages/kit/test/prerendering/trailing-slash/src/hooks.js b/packages/kit/test/prerendering/paths-base/src/hooks.server.js
similarity index 100%
rename from packages/kit/test/prerendering/trailing-slash/src/hooks.js
rename to packages/kit/test/prerendering/paths-base/src/hooks.server.js
diff --git a/packages/kit/test/prerendering/trailing-slash/src/hooks.server.js b/packages/kit/test/prerendering/trailing-slash/src/hooks.server.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/kit/test/utils.d.ts b/packages/kit/test/utils.d.ts
index abb0004e8..6bf48601a 100644
--- a/packages/kit/test/utils.d.ts
+++ b/packages/kit/test/utils.d.ts
@@ -22,7 +22,10 @@ export const test: TestType<
 			};
 			clicknav: (selector: string, options?: { timeout?: number }) => Promise<void>;
 			in_view: (selector: string) => Promise<boolean>;
-			read_errors: (href: string) => string;
+			/**
+			 * `handleError` defines the shape
+			 */
+			read_errors: (href: string) => Record<string, any>;
 		},
 	PlaywrightWorkerArgs & PlaywrightWorkerOptions
 >;