diff --git a/.changeset/quiet-camels-shop.md b/.changeset/quiet-camels-shop.md
new file mode 100644
index 000000000000..065fbdf5fb97
--- /dev/null
+++ b/.changeset/quiet-camels-shop.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] add `error.html` page, rename `kit.config.files.template` to `kit.config.files.appTemplate`
diff --git a/documentation/docs/01-project-structure.md b/documentation/docs/01-project-structure.md
index 737c5b9f8da1..d93e514fd012 100644
--- a/documentation/docs/01-project-structure.md
+++ b/documentation/docs/01-project-structure.md
@@ -14,6 +14,7 @@ my-project/
│ ├ routes/
│ │ └ [your routes]
│ ├ app.html
+│ ├ error.html
│ └ hooks.js
├ static/
│ └ [your static assets]
@@ -41,6 +42,9 @@ The `src` directory contains the meat of your project.
- `%sveltekit.body%` — the markup for a rendered page. Typically this lives inside a `
` or other element, rather than directly inside ``, to prevent bugs caused by browser extensions injecting elements that are then destroyed by the hydration process
- `%sveltekit.assets%` — either [`paths.assets`](/docs/configuration#paths), if specified, or a relative path to [`paths.base`](/docs/configuration#base)
- `%sveltekit.nonce%` — a [CSP](/docs/configuration#csp) nonce for manually included links and scripts, if used
+- `error.html` (optional) is the page that is rendered when everything else fails. It can contain the following placeholders:
+ - `%sveltekit.status%` — the HTTP status
+ - `%sveltekit.message%` — the error message
- `hooks.js` (optional) contains your application's [hooks](/docs/hooks)
- `service-worker.js` (optional) contains your [service worker](/docs/service-workers)
diff --git a/documentation/docs/03-routing.md b/documentation/docs/03-routing.md
index 5dff407cc72e..03468fa3308b 100644
--- a/documentation/docs/03-routing.md
+++ b/documentation/docs/03-routing.md
@@ -190,7 +190,7 @@ If an error occurs during `load`, SvelteKit will render a default error page. Yo
{$page.status}: {$page.error.message}
```
-SvelteKit will 'walk up the tree' looking for the closest error boundary — if the file above didn't exist it would try `src/routes/blog/+error.svelte` and `src/routes/+error.svelte` before rendering the default error page.
+SvelteKit will 'walk up the tree' looking for the closest error boundary — if the file above didn't exist it would try `src/routes/blog/+error.svelte` and `src/routes/+error.svelte` before rendering the default error page. If _that_ fails, SvelteKit will bail out and render a static fallback error page, which you can customise by creating a `src/error.html` file.
### +layout
diff --git a/documentation/docs/15-configuration.md b/documentation/docs/15-configuration.md
index 6b39c660ea14..81f858280a11 100644
--- a/documentation/docs/15-configuration.md
+++ b/documentation/docs/15-configuration.md
@@ -40,7 +40,8 @@ const config = {
params: 'src/params',
routes: 'src/routes',
serviceWorker: 'src/service-worker',
- template: 'src/app.html'
+ appTemplate: 'src/app.html',
+ errorTemplate: 'src/error.html'
},
inlineStyleThreshold: 0,
methodOverride: {
diff --git a/packages/kit/src/core/config/default-error.html b/packages/kit/src/core/config/default-error.html
new file mode 100644
index 000000000000..eae4dbc89673
--- /dev/null
+++ b/packages/kit/src/core/config/default-error.html
@@ -0,0 +1,56 @@
+
+
+
+
+ %sveltekit.message%
+
+
+
+
+
+ %sveltekit.status%
+
+
%sveltekit.message%
+
+
+
+
diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js
index f903bb086b05..3f5d53dc4f11 100644
--- a/packages/kit/src/core/config/index.js
+++ b/packages/kit/src/core/config/index.js
@@ -10,11 +10,11 @@ import options from './options.js';
* @param {import('types').ValidatedConfig} config
*/
export function load_template(cwd, config) {
- const { template } = config.kit.files;
- const relative = path.relative(cwd, template);
+ const { appTemplate } = config.kit.files;
+ const relative = path.relative(cwd, appTemplate);
- if (fs.existsSync(template)) {
- const contents = fs.readFileSync(template, 'utf8');
+ if (fs.existsSync(appTemplate)) {
+ const contents = fs.readFileSync(appTemplate, 'utf8');
// TODO remove this for 1.0
const match = /%svelte\.([a-z]+)%/.exec(contents);
@@ -34,7 +34,17 @@ export function load_template(cwd, config) {
throw new Error(`${relative} does not exist`);
}
- return fs.readFileSync(template, 'utf-8');
+ return fs.readFileSync(appTemplate, 'utf-8');
+}
+
+/**
+ * Loads the error page (src/error.html by default) if it exists.
+ * Falls back to a generic error page content.
+ * @param {import('types').ValidatedConfig} config
+ */
+export function load_error_page(config) {
+ const { errorTemplate } = config.kit.files;
+ return fs.readFileSync(errorTemplate, 'utf-8');
}
/**
@@ -64,10 +74,19 @@ function process_config(config, { cwd = process.cwd() } = {}) {
validated.kit.outDir = path.resolve(cwd, validated.kit.outDir);
for (const key in validated.kit.files) {
+ // 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 (!fs.existsSync(validated.kit.files.errorTemplate)) {
+ validated.kit.files.errorTemplate = url.fileURLToPath(
+ new URL('./default-error.html', import.meta.url)
+ );
+ }
+
return validated;
}
diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js
index aa748ec0a92a..da3274f2d384 100644
--- a/packages/kit/src/core/config/index.spec.js
+++ b/packages/kit/src/core/config/index.spec.js
@@ -84,7 +84,9 @@ const get_defaults = (prefix = '') => ({
params: join(prefix, 'src/params'),
routes: join(prefix, 'src/routes'),
serviceWorker: join(prefix, 'src/service-worker'),
- template: join(prefix, 'src/app.html')
+ appTemplate: join(prefix, 'src/app.html'),
+ errorTemplate: join(prefix, 'src/error.html'),
+ template: undefined
},
headers: undefined,
host: undefined,
@@ -381,6 +383,9 @@ test('load default config (esm)', async () => {
const defaults = get_defaults(cwd + '/');
defaults.kit.version.name = config.kit.version.name;
+ defaults.kit.files.errorTemplate = fileURLToPath(
+ new URL('./default-error.html', import.meta.url)
+ );
assert.equal(config, defaults);
});
diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js
index d460596a10e8..4a619faca6b6 100644
--- a/packages/kit/src/core/config/options.js
+++ b/packages/kit/src/core/config/options.js
@@ -140,7 +140,12 @@ const options = object(
params: string(join('src', 'params')),
routes: string(join('src', 'routes')),
serviceWorker: string(join('src', 'service-worker')),
- template: string(join('src', 'app.html'))
+ appTemplate: string(join('src', 'app.html')),
+ errorTemplate: string(join('src', 'error.html')),
+ // TODO: remove this for the 1.0 release
+ template: error(
+ () => 'config.kit.files.template has been renamed to config.kit.files.appTemplate'
+ )
}),
// TODO: remove this for the 1.0 release
diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js
index 4b9782a7b963..7518965e43c7 100644
--- a/packages/kit/src/exports/vite/build/build_server.js
+++ b/packages/kit/src/exports/vite/build/build_server.js
@@ -2,7 +2,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 { load_template } from '../../../core/config/index.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';
import { s } from '../../../utils/misc.js';
@@ -14,9 +14,10 @@ import { s } from '../../../utils/misc.js';
* has_service_worker: boolean;
* runtime: string;
* template: string;
+ * error_page: string;
* }} opts
*/
-const server_template = ({ config, hooks, has_service_worker, runtime, template }) => `
+const server_template = ({ config, hooks, has_service_worker, runtime, template, error_page }) => `
import root from '__GENERATED__/root.svelte';
import { respond } from '${runtime}/server/index.js';
import { set_paths, assets, base } from '${runtime}/paths.js';
@@ -24,12 +25,16 @@ import { set_prerendering } from '${runtime}/env.js';
import { set_private_env } from '${runtime}/env-private.js';
import { set_public_env } from '${runtime}/env-public.js';
-const template = ({ head, body, assets, nonce }) => ${s(template)
+const app_template = ({ head, body, assets, nonce }) => ${s(template)
.replace('%sveltekit.head%', '" + head + "')
.replace('%sveltekit.body%', '" + body + "')
.replace(/%sveltekit\.assets%/g, '" + assets + "')
.replace(/%sveltekit\.nonce%/g, '" + nonce + "')};
+const error_template = ({ status, message }) => ${s(error_page)
+ .replace(/%sveltekit\.status%/g, '" + status + "')
+ .replace(/%sveltekit\.message%/g, '" + message + "')};
+
let read = null;
set_paths(${s(config.kit.paths)});
@@ -78,8 +83,9 @@ export class Server {
root,
service_worker: ${has_service_worker ? "base + '/service-worker.js'" : 'null'},
router: ${s(config.kit.browser.router)},
- template,
- template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
+ app_template,
+ app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
+ error_template,
trailing_slash: ${s(config.kit.trailingSlash)}
};
}
@@ -205,7 +211,8 @@ export async function build_server(options, client) {
hooks: app_relative(hooks_file),
has_service_worker: config.kit.serviceWorker.register && !!service_worker_entry_file,
runtime: posixify(path.relative(build_dir, runtime_directory)),
- template: load_template(cwd, config)
+ template: load_template(cwd, config),
+ error_page: load_error_page(config)
})
);
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index a74cdddd20ed..cbefff2fb7cc 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -7,7 +7,7 @@ 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 { load_template } from '../../../core/config/index.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';
@@ -371,6 +371,7 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) {
}
const template = load_template(cwd, svelte_config);
+ const error_page = load_error_page(svelte_config);
const rendered = await respond(
request,
@@ -416,7 +417,7 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) {
read: (file) => fs.readFileSync(path.join(svelte_config.kit.files.assets, file)),
root,
router: svelte_config.kit.browser.router,
- template: ({ head, body, assets, nonce }) => {
+ app_template: ({ head, body, assets, nonce }) => {
return (
template
.replace(/%sveltekit\.assets%/g, assets)
@@ -426,7 +427,12 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) {
.replace('%sveltekit.body%', () => body)
);
},
- template_contains_nonce: template.includes('%sveltekit.nonce%'),
+ app_template_contains_nonce: template.includes('%sveltekit.nonce%'),
+ error_template: ({ status, message }) => {
+ return error_page
+ .replace(/%sveltekit\.status%/g, String(status))
+ .replace(/%sveltekit\.message%/g, message);
+ },
trailing_slash: svelte_config.kit.trailingSlash
},
{
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 64c470be62f6..b6c0aef0c58e 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -832,7 +832,7 @@ export function create_client({ target, base, trailing_slash }) {
// if we get here, it's because the root `load` function failed,
// and we need to fall back to the server
- native_navigation(url);
+ await native_navigation(url);
return;
}
} else {
@@ -886,7 +886,7 @@ export function create_client({ target, base, trailing_slash }) {
server_data_node = server_data.nodes[0] ?? null;
} catch {
// at this point we have no choice but to fall back to the server
- native_navigation(url);
+ await native_navigation(url);
// @ts-expect-error
return;
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index 5c03fb045901..ee14ee442f38 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -3,7 +3,7 @@ import { render_page } from './page/index.js';
import { render_response } from './page/render.js';
import { respond_with_error } from './page/respond_with_error.js';
import { coalesce_to_error } from '../../utils/error.js';
-import { serialize_error, GENERIC_ERROR } from './utils.js';
+import { serialize_error, GENERIC_ERROR, static_error_page } from './utils.js';
import { decode_params, disable_search, normalize_path } from '../../utils/url.js';
import { exec } from '../../utils/routing.js';
import { negotiate } from '../../utils/http.js';
@@ -360,10 +360,7 @@ export async function respond(request, options, state) {
});
} catch (/** @type {unknown} */ e) {
const error = coalesce_to_error(e);
-
- return new Response(options.dev ? error.stack : error.message, {
- status: 500
- });
+ return static_error_page(options, 500, error.message);
}
}
}
diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js
index d350441a7158..ce9c3e0c49fc 100644
--- a/packages/kit/src/runtime/server/page/index.js
+++ b/packages/kit/src/runtime/server/page/index.js
@@ -2,7 +2,7 @@ import { devalue } from 'devalue';
import { negotiate } from '../../../utils/http.js';
import { render_response } from './render.js';
import { respond_with_error } from './respond_with_error.js';
-import { method_not_allowed, error_to_pojo, allowed_methods } from '../utils.js';
+import { method_not_allowed, error_to_pojo, allowed_methods, static_error_page } from '../utils.js';
import { create_fetch } from './fetch.js';
import { HttpError, Redirect } from '../../control.js';
import { error, json } from '../../../exports/index.js';
@@ -281,12 +281,11 @@ 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 a plain text response
- // TODO since the requester is expecting HTML, maybe it makes sense to
- // doll this up a bit
- return new Response(
- error instanceof HttpError ? error.message : options.get_stack(error),
- { status }
+ // which means we have to fall back to error.html
+ return static_error_page(
+ options,
+ status,
+ /** @type {HttpError | Error} */ (error).message
);
}
} else {
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 9ed1d1e647a1..8a47d0f85456 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -49,7 +49,7 @@ export async function render_response({
throw new Error('Cannot use prerendering if config.kit.csp.mode === "nonce"');
}
- if (options.template_contains_nonce) {
+ if (options.app_template_contains_nonce) {
throw new Error('Cannot use prerendering if page template contains %sveltekit.nonce%');
}
}
@@ -346,7 +346,7 @@ export async function render_response({
// TODO flush chunks as early as we can
const html =
(await resolve_opts.transformPageChunk({
- html: options.template({ head, body, assets, nonce: /** @type {string} */ (csp.nonce) }),
+ html: options.app_template({ head, body, assets, nonce: /** @type {string} */ (csp.nonce) }),
done: true
})) || '';
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 b0f87a5f5859..50e53ed439c5 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,7 @@
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 } from '../utils.js';
+import { GENERIC_ERROR, static_error_page } from '../utils.js';
import { create_fetch } from './fetch.js';
/**
@@ -87,8 +87,6 @@ export async function respond_with_error({ event, options, state, status, error,
options.handle_error(error, event);
- return new Response(error.stack, {
- status: 500
- });
+ return static_error_page(options, 500, error.message);
}
}
diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js
index 09b36614ebc9..16baabee3edb 100644
--- a/packages/kit/src/runtime/server/utils.js
+++ b/packages/kit/src/runtime/server/utils.js
@@ -135,3 +135,17 @@ export function data_response(data) {
});
}
}
+
+/**
+ * Return as a response that renders the error.html
+ *
+ * @param {import('types').SSROptions} options
+ * @param {number} status
+ * @param {string} message
+ */
+export function static_error_page(options, status, message) {
+ return new Response(options.error_template({ status, message }), {
+ headers: { 'content-type': 'text/html; charset=utf-8' },
+ status
+ });
+}
diff --git a/packages/kit/test/apps/basics/src/error.html b/packages/kit/test/apps/basics/src/error.html
new file mode 100644
index 000000000000..14d6424789f9
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/error.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Error
+
+
+
Error - %sveltekit.status%
+
This is the static error page with the following message: %sveltekit.message%
+
+
diff --git a/packages/kit/test/apps/basics/src/routes/+layout.server.js b/packages/kit/test/apps/basics/src/routes/+layout.server.js
index bacb24019ecc..cb10abf04b64 100644
--- a/packages/kit/test/apps/basics/src/routes/+layout.server.js
+++ b/packages/kit/test/apps/basics/src/routes/+layout.server.js
@@ -1,4 +1,16 @@
+let should_fail = false;
+/**
+ * @param {boolean} value
+ */
+export function set_should_fail(value) {
+ should_fail = value;
+}
+
export async function load() {
+ if (should_fail) {
+ set_should_fail(false);
+ throw new Error('Failed to load');
+ }
// Do NOT make this load function depend on something which would cause it to rerun
return {
rootlayout: 'rootlayout'
diff --git a/packages/kit/test/apps/basics/src/routes/errors/error-html/+page.svelte b/packages/kit/test/apps/basics/src/routes/errors/error-html/+page.svelte
new file mode 100644
index 000000000000..2fda2e2e0f14
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/errors/error-html/+page.svelte
@@ -0,0 +1,8 @@
+
+
+
diff --git a/packages/kit/test/apps/basics/src/routes/errors/error-html/make-root-fail/+server.js b/packages/kit/test/apps/basics/src/routes/errors/error-html/make-root-fail/+server.js
new file mode 100644
index 000000000000..7c3f14cfd285
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/errors/error-html/make-root-fail/+server.js
@@ -0,0 +1,6 @@
+import { set_should_fail } from '../../../+layout.server';
+
+export function GET() {
+ set_should_fail(true);
+ return new Response();
+}
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 03da4032df27..fd73413f6823 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -305,6 +305,15 @@ test.describe('Errors', () => {
);
expect(await page.innerHTML('h1')).toBe('401');
});
+
+ test('Root error falls back to error.html', async ({ page }) => {
+ await page.goto('/errors/error-html');
+ await page.click('button');
+ expect(await page.textContent('h1')).toBe('Error - 500');
+ expect(await page.textContent('p')).toBe(
+ 'This is the static error page with the following message: Failed to load'
+ );
+ });
});
test.describe('Load', () => {
diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js
index accce4d94146..5bef3229bcaf 100644
--- a/packages/kit/test/apps/options/svelte.config.js
+++ b/packages/kit/test/apps/options/svelte.config.js
@@ -11,7 +11,7 @@ const config = {
assets: 'public',
lib: 'source/components',
routes: 'source/pages',
- template: 'source/template.html',
+ appTemplate: 'source/template.html',
// 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/types/index.d.ts b/packages/kit/types/index.d.ts
index f5033d642bea..780013c63ed4 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -142,7 +142,8 @@ export interface KitConfig {
params?: string;
routes?: string;
serviceWorker?: string;
- template?: string;
+ appTemplate?: string;
+ errorTemplate?: string;
};
inlineStyleThreshold?: number;
methodOverride?: {
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 2543bc14c35f..21f21f08f6ea 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -309,7 +309,7 @@ export interface SSROptions {
root: SSRComponent['default'];
router: boolean;
service_worker?: string;
- template({
+ app_template({
head,
body,
assets,
@@ -320,7 +320,8 @@ export interface SSROptions {
assets: string;
nonce: string;
}): string;
- template_contains_nonce: boolean;
+ app_template_contains_nonce: boolean;
+ error_template({ message, status }: { message: string; status: number }): string;
trailing_slash: TrailingSlash;
}