diff --git a/.changeset/bright-tips-beam.md b/.changeset/bright-tips-beam.md
new file mode 100644
index 000000000000..831f230aa994
--- /dev/null
+++ b/.changeset/bright-tips-beam.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Added a "never" value to the config.kit.ssr option that prevents pages from being evaluated on the server on both ssr and prerendering.
diff --git a/documentation/docs/11-ssr-and-javascript.md b/documentation/docs/11-ssr-and-javascript.md
index fe9b3e0721a7..9e4315bb6993 100644
--- a/documentation/docs/11-ssr-and-javascript.md
+++ b/documentation/docs/11-ssr-and-javascript.md
@@ -14,7 +14,7 @@ Disabling [server-side rendering](#appendix-ssr) effectively turns your SvelteKi
> In most situations this is not recommended: see [the discussion in the appendix](#appendix-ssr). Consider whether it's truly appropriate to disable and don't simply disable SSR because you've hit an issue with it.
-You can disable SSR app-wide with the [`ssr` config option](#configuration-ssr), or a page-level `ssr` export:
+You can disable SSR on a page-level with a `ssr` export. Page-level `ssr` exports must be boolean values, if another value is provided it will be cast into a boolean.
```html
```
+You can also disable SSR app-wide with the [`ssr` config option](#configuration-ssr), using a boolean or `"never"`. In case you use `"never"`, all page-level `ssr` exports will be completely ignored regardless of their value, only consider using this if you are sure you don't need SSR.
+
### router
SvelteKit includes a [client-side router](#appendix-routing) that intercepts navigations (from the user clicking on links, or interacting with the back/forward buttons) and updates the page contents, rather than letting the browser handle the navigation by reloading.
diff --git a/documentation/docs/14-configuration.md b/documentation/docs/14-configuration.md
index e7e8d5a775e1..fbebd31ef803 100644
--- a/documentation/docs/14-configuration.md
+++ b/documentation/docs/14-configuration.md
@@ -199,7 +199,8 @@ An object containing zero or more of the following values:
### ssr
-Enables or disables [server-side rendering](#ssr-and-javascript-ssr) app-wide.
+- `true` or `false` — Enables or disables [server-side rendering](#ssr-and-javascript-ssr) app-wide and allows pages to override it locally.
+- `"never"` — Prevents all pages from being evaluated on the server, on both [server-side rendering](#ssr-and-javascript-ssr) and [prerendering](#ssr-and-javascript-prerender), can't be overriden by page-level exports. Only consider using this option if you don't need SSR (like when building an SPA).
### target
diff --git a/documentation/faq/80-integrations.md b/documentation/faq/80-integrations.md
index 7b7d866c5fa7..0d2005318604 100644
--- a/documentation/faq/80-integrations.md
+++ b/documentation/faq/80-integrations.md
@@ -85,6 +85,24 @@ onMount(() => {
});
```
+But if your app doesn't use SSR, you can set the `ssr` option to `'never'`:
+
+```js
+export default {
+ kit: {
+ // ...
+ ssr: 'never'
+ // ...
+ }
+}
+```
+
+```js
+import { method } from 'some-browser-only-library';
+
+method('hello world!');
+
+```
### How do I use Firebase?
Please use SDK v9 which provides a modular SDK approach that's currently in beta. The old versions are very difficult to get working especially with SSR and also resulted in a much larger client download size. Even with v9, most users need to set `kit.ssr: false` until [vite#4425](https://github.com/vitejs/vite/issues/4425) and [firebase-js-sdk#4846](https://github.com/firebase/firebase-js-sdk/issues/4846) are solved.
diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js
index 0921e1a809a0..a3d215cffdcb 100644
--- a/packages/kit/src/core/build/index.js
+++ b/packages/kit/src/core/build/index.js
@@ -259,32 +259,35 @@ async function build_server(
}
}
+ const allow_ssr = config.kit.ssr !== 'never';
+
/** @type {Record} */
const metadata_lookup = {};
-
- manifest.components.forEach((file) => {
- const js_deps = new Set();
- const css_deps = new Set();
-
- find_deps(file, js_deps, css_deps);
-
- const js = Array.from(js_deps);
- const css = Array.from(css_deps);
-
- const styles = config.kit.amp
- ? Array.from(css_deps).map((url) => {
- const resolved = `${output_dir}/client/${config.kit.appDir}/${url}`;
- return fs.readFileSync(resolved, 'utf-8');
- })
- : [];
-
- metadata_lookup[file] = {
- entry: client_manifest[file].file,
- css,
- js,
- styles
- };
- });
+ if (allow_ssr) {
+ manifest.components.forEach((file) => {
+ const js_deps = new Set();
+ const css_deps = new Set();
+
+ find_deps(file, js_deps, css_deps);
+
+ const js = Array.from(js_deps);
+ const css = Array.from(css_deps);
+
+ const styles = config.kit.amp
+ ? Array.from(css_deps).map((url) => {
+ const resolved = `${output_dir}/client/${config.kit.appDir}/${url}`;
+ return fs.readFileSync(resolved, 'utf-8');
+ })
+ : [];
+
+ metadata_lookup[file] = {
+ entry: client_manifest[file].file,
+ css,
+ js,
+ styles
+ };
+ });
+ }
/** @type {Set} */
const entry_js = new Set();
@@ -412,21 +415,22 @@ async function build_server(
externalFetch: hooks.externalFetch || fetch
});
- const module_lookup = {
- ${manifest.components.map(file => `${s(file)}: () => import(${s(app_relative(file))})`)}
+ ${allow_ssr ?
+ `const module_lookup = {
+ ${manifest.components.map((file) => `${s(file)}: () => import(${s(app_relative(file))})`)}
};
-
- const metadata_lookup = ${s(metadata_lookup)};
+ const metadata_lookup = ${s(metadata_lookup)};` : ''}
async function load_component(file) {
- const { entry, css, js, styles } = metadata_lookup[file];
+ ${allow_ssr ?
+ `const { entry, css, js, styles } = metadata_lookup[file];
return {
module: await module_lookup[file](),
entry: assets + ${s(prefix)} + entry,
css: css.map(dep => assets + ${s(prefix)} + dep),
js: js.map(dep => assets + ${s(prefix)} + dep),
styles
- };
+ };` : 'throw new Error(`Cannot evaluate pages on the server when config.kit.ssr is "never". (${file})`)'}
}
export function render(request, {
diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js
index 1a76bb14e113..fe857434e377 100644
--- a/packages/kit/src/core/config/options.js
+++ b/packages/kit/src/core/config/options.js
@@ -165,7 +165,7 @@ const options = object(
files: fun((filename) => !/\.DS_STORE/.test(filename))
}),
- ssr: boolean(true),
+ ssr: list([true, false, 'never']),
target: string(null),
@@ -275,16 +275,21 @@ function boolean(fallback) {
}
/**
- * @param {string[]} options
+ * @param {unknown[]} options
* @returns {Validator}
*/
function list(options, fallback = options[0]) {
return validate(fallback, (input, keypath) => {
+ /** @param {unknown} i */
+ const stringify = (i) => (typeof i === 'string' ? `"${i}"` : `${i}`);
if (!options.includes(input)) {
- // prettier-ignore
- const msg = options.length > 2
- ? `${keypath} should be one of ${options.slice(0, -1).map(input => `"${input}"`).join(', ')} or "${options[options.length - 1]}"`
- : `${keypath} should be either "${options[0]}" or "${options[1]}"`;
+ const msg =
+ options.length > 2
+ ? `${keypath} should be one of ${options
+ .slice(0, -1)
+ .map(stringify)
+ .join(', ')} or ${stringify(options[options.length - 1])}`
+ : `${keypath} should be either ${stringify(options[0])} or ${stringify(options[1])}`;
throw new Error(msg);
}
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index ea126a86d741..29a535e6c5c8 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -12,7 +12,11 @@ const s = JSON.stringify;
* branch: Array;
* options: import('types/internal').SSRRenderOptions;
* $session: any;
- * page_config: { hydrate: boolean, router: boolean, ssr: boolean };
+ * page_config: {
+ * hydrate: boolean,
+ * router: boolean,
+ * ssr: import('types/internal').SSROption
+ * };
* status: number;
* error?: Error,
* page?: import('types/page').Page
@@ -43,7 +47,8 @@ export async function render_response({
error.stack = options.get_stack(error);
}
- if (page_config.ssr) {
+ // excludes false and 'never'
+ if (page_config.ssr === true) {
branch.forEach(({ node, loaded, fetched, uses_credentials }) => {
if (node.css) node.css.forEach((url) => css.add(url));
if (node.js) node.js.forEach((url) => js.add(url));
@@ -124,9 +129,9 @@ export async function render_response({
})},
host: ${page && page.host ? s(page.host) : 'location.host'},
route: ${!!page_config.router},
- spa: ${!page_config.ssr},
+ spa: ${page_config.ssr !== true},
trailing_slash: ${s(options.trailing_slash)},
- hydrate: ${page_config.ssr && page_config.hydrate ? `{
+ hydrate: ${page_config.ssr === true && page_config.hydrate ? `{
status: ${status},
error: ${serialize_error(error)},
nodes: [
diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js
index 5e260ce04225..fafa5ecf4323 100644
--- a/packages/kit/src/runtime/server/page/respond.js
+++ b/packages/kit/src/runtime/server/page/respond.js
@@ -24,6 +24,19 @@ import { coalesce_to_error } from '../../../utils/error.js';
*/
export async function respond(opts) {
const { request, options, state, $session, route } = opts;
+ if (options.ssr === 'never') {
+ return await render_response({
+ branch: [],
+ $session,
+ options,
+ page_config: {
+ hydrate: true,
+ router: true,
+ ssr: false
+ },
+ status: 200
+ });
+ }
/** @type {Array} */
let nodes;
@@ -227,7 +240,7 @@ export async function respond(opts) {
*/
function get_page_config(leaf, options) {
return {
- ssr: 'ssr' in leaf ? !!leaf.ssr : options.ssr,
+ ssr: 'ssr' in leaf ? !!leaf.ssr : options.ssr === true,
router: 'router' in leaf ? !!leaf.router : options.router,
hydrate: 'hydrate' in leaf ? !!leaf.hydrate : options.hydrate
};
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 986a788bb37e..481fdc283e34 100644
--- a/packages/kit/src/runtime/server/page/respond_with_error.js
+++ b/packages/kit/src/runtime/server/page/respond_with_error.js
@@ -20,9 +20,6 @@ import { coalesce_to_error } from '../../../utils/error.js';
* }} opts
*/
export async function respond_with_error({ request, options, state, $session, status, error }) {
- const default_layout = await options.load_component(options.manifest.layout);
- const default_error = await options.load_component(options.manifest.error);
-
const page = {
host: request.host,
path: request.path,
@@ -30,43 +27,51 @@ export async function respond_with_error({ request, options, state, $session, st
params: {}
};
- // error pages don't fall through, so we know it's not undefined
- const loaded = /** @type {Loaded} */ (
- await load_node({
- request,
- options,
- state,
- route: null,
- page,
- node: default_layout,
- $session,
- stuff: {},
- prerender_enabled: is_prerender_enabled(options, default_error, state),
- is_leaf: false,
- is_error: false
- })
- );
+ /** @type {Loaded[]} */
+ let branch = [];
- const branch = [
- loaded,
- /** @type {Loaded} */ (
+ if (options.ssr !== 'never') {
+ const default_layout = await options.load_component(options.manifest.layout);
+ const default_error = await options.load_component(options.manifest.error);
+
+ // error pages don't fall through, so we know it's not undefined
+ const loaded = /** @type {Loaded} */ (
await load_node({
request,
options,
state,
route: null,
page,
- node: default_error,
+ node: default_layout,
$session,
- stuff: loaded ? loaded.stuff : {},
+ stuff: {},
prerender_enabled: is_prerender_enabled(options, default_error, state),
is_leaf: false,
- is_error: true,
- status,
- error
+ is_error: false
})
- )
- ];
+ );
+
+ branch = [
+ loaded,
+ /** @type {Loaded} */ (
+ await load_node({
+ request,
+ options,
+ state,
+ route: null,
+ page,
+ node: default_error,
+ $session,
+ stuff: loaded ? loaded.stuff : {},
+ prerender_enabled: is_prerender_enabled(options, default_error, state),
+ is_leaf: false,
+ is_error: true,
+ status,
+ error
+ })
+ )
+ ];
+ }
try {
return await render_response({
diff --git a/packages/kit/test/apps/spa/package.json b/packages/kit/test/apps/spa/package.json
new file mode 100644
index 000000000000..b75528dfea7f
--- /dev/null
+++ b/packages/kit/test/apps/spa/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "test-spa",
+ "private": true,
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "../../../svelte-kit.js dev",
+ "build": "../../../svelte-kit.js build",
+ "preview": "../../../svelte-kit.js preview"
+ },
+ "devDependencies": {
+ "@sveltejs/kit": "workspace:*",
+ "@sveltejs/adapter-node": "workspace:*",
+ "svelte": "^3.43.0"
+ },
+ "type": "module"
+}
diff --git a/packages/kit/test/apps/spa/src/app.html b/packages/kit/test/apps/spa/src/app.html
new file mode 100644
index 000000000000..97f318d90764
--- /dev/null
+++ b/packages/kit/test/apps/spa/src/app.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ %svelte.head%
+
+
+ %svelte.body%
+
+
diff --git a/packages/kit/test/apps/spa/src/routes/client-code/_client_dep.js b/packages/kit/test/apps/spa/src/routes/client-code/_client_dep.js
new file mode 100644
index 000000000000..6ab4531427b5
--- /dev/null
+++ b/packages/kit/test/apps/spa/src/routes/client-code/_client_dep.js
@@ -0,0 +1 @@
+export const root = /** @type {HTMLElement} */ (document.getElementById('svelte'));
diff --git a/packages/kit/test/apps/spa/src/routes/client-code/_tests.js b/packages/kit/test/apps/spa/src/routes/client-code/_tests.js
new file mode 100644
index 000000000000..6f692ba59c78
--- /dev/null
+++ b/packages/kit/test/apps/spa/src/routes/client-code/_tests.js
@@ -0,0 +1,22 @@
+import * as assert from 'uvu/assert';
+
+/** @type {import('test').TestMaker} */
+export default function (test) {
+ test('page with client only code', '/client-code', async ({ page, js }) => {
+ if (js) {
+ await page.waitForSelector('span');
+ assert.equal(await page.textContent('span'), 'App root is div#svelte');
+ } else {
+ assert.ok(await page.evaluate(() => !document.querySelector('span')));
+ }
+ });
+
+ test('page with client only dependency', '/client-code/dep', async ({ page, js }) => {
+ if (js) {
+ await page.waitForSelector('span');
+ assert.equal(await page.textContent('span'), 'App root is div#svelte');
+ } else {
+ assert.ok(await page.evaluate(() => !document.querySelector('span')));
+ }
+ });
+}
diff --git a/packages/kit/test/apps/spa/src/routes/client-code/dep.svelte b/packages/kit/test/apps/spa/src/routes/client-code/dep.svelte
new file mode 100644
index 000000000000..6b6ee2f7290f
--- /dev/null
+++ b/packages/kit/test/apps/spa/src/routes/client-code/dep.svelte
@@ -0,0 +1,5 @@
+
+
+App root is {root.localName}#{root.id}
diff --git a/packages/kit/test/apps/spa/src/routes/client-code/index.svelte b/packages/kit/test/apps/spa/src/routes/client-code/index.svelte
new file mode 100644
index 000000000000..158a776a26c3
--- /dev/null
+++ b/packages/kit/test/apps/spa/src/routes/client-code/index.svelte
@@ -0,0 +1,5 @@
+
+
+App root is {appRoot.localName}#{appRoot.id}
diff --git a/packages/kit/test/apps/spa/svelte.config.js b/packages/kit/test/apps/spa/svelte.config.js
new file mode 100644
index 000000000000..0efb6d12ee5c
--- /dev/null
+++ b/packages/kit/test/apps/spa/svelte.config.js
@@ -0,0 +1,9 @@
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ ssr: 'never',
+ target: '#svelte'
+ }
+};
+
+export default config;
diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts
index 3e509fa0981d..cf4d6c4d740d 100644
--- a/packages/kit/types/config.d.ts
+++ b/packages/kit/types/config.d.ts
@@ -1,6 +1,6 @@
import { UserConfig as ViteConfig } from 'vite';
import { RecursiveRequired } from './helper';
-import { Logger, TrailingSlash } from './internal';
+import { Logger, SSROption, TrailingSlash } from './internal';
export interface AdapterUtils {
log: Logger;
@@ -68,7 +68,7 @@ export interface Config {
serviceWorker?: {
files?(filepath: string): boolean;
};
- ssr?: boolean;
+ ssr?: SSROption;
target?: string;
trailingSlash?: TrailingSlash;
vite?: ViteConfig | (() => ViteConfig);
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 9fe00ab6d98d..32108fb6e34b 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -20,6 +20,8 @@ export interface Logger {
info(msg: string): void;
}
+export type SSROption = boolean | 'never';
+
export interface SSRComponent {
ssr?: boolean;
router?: boolean;
@@ -130,7 +132,7 @@ export interface SSRRenderOptions {
root: SSRComponent['default'];
router: boolean;
service_worker?: string;
- ssr: boolean;
+ ssr: SSROption;
target: string;
template({ head, body }: { head: string; body: string }): string;
trailing_slash: TrailingSlash;