migration guide now available at #5774!
update: thank you! #5748 (comment)
updated! #5748 (comment)
I've edited this post with the amendments described in that comment.
Alright friends, strap in — this will be a long read, but hopefully one that leaves you as excited about SvelteKit's future as we are. What follows is a proposal for a set of changes that address the wartiest bits of SvelteKit's design, particularly the more confusing aspects of load — it should enable us to close #3021, #3751, #4274, #4656, #4801, #4815, #4911, #5218, #5467, #5537, #5633, #5669, #5732, #5750 and #5532, and it will reframe discussions taking place in a couple of dozen other issues.
It will involve some migration work proportional to the size of your app. For that, we're sorry. (We're particularly sorry to people who have created educational content around the current design.) Because of the scale of the changes, we're taking the unusual step of building a migration script that will do as much as can be automated and annotate your codebase for the remainder with errors like this:
throw new Error( '@migration task: Update handler return values (https://github.com/sveltejs/kit/issues/xxxx#yyyy)' );
tl;dr
Please don't comment on the discussion after only reading this section! It's here to prime the pump and to give you something to refer back to once you've digested the entire thing, including the rationale for the various design decisions.
With that caveat, this proposal:
- replaces the current file-and-directory based routing with something more explicit: directories dictate which routes are created, and route files such as
+page.sveltedictate how those routes behave - moves
loadinto a separate+page.jsfile along withexport const prerenderetc - adds page-level
export const ssr = falsecontrol - radically simplifies the
loadAPI - improves SSR and navigation performance by allowing
loadfunctions to run concurrently - simplifies endpoints
- replaces
pageprops with a singledataprop, plus anerrorsprop for validation errors fromPOST/PUT/PATCHhandlers, and... - ...insodoing, vastly improves the TypeScript experience
Let's dive in.
Why are we doing this?
The current filesystem-based routing design has some weak spots:
- There are multiple ways to express a route.
src/routes/foo.svelteandsrc/routes/foo/index.svelteare equivalent, and having two ways to do things is always a source of confusion. Each has downsides — too manyindex.sveltefiles open in your editor gets confusing, butfoo.sveltemakes it awkward to colocate related files. I frequently find myself movingfoo.sveltetofoo/index.svelteas the route becomes more complex (e.g. it needs a dedicated error page, or it gains a child route, or I need to break something out into a separate component, or it needs a page endpoint, and so on). These changes are costly and annoying, and I always kick myself for not just always using folders. - It often makes sense for components and modules to be colocated with the route that uses them. Many people will create a
src/routes/foo/Widget.svelte, unaware that this creates an unwanted and broken/foo/Widgetroute. It's possible to mark files as 'private' withconfig.kit.routes(the default is to treat_Widget.svelte, the aesthetics of which make me shudder, as private) but this is a little convoluted. - Speaking of aesthetics,
__layout.svelteand__error.svelteare just... ugly. - A file like
src/routes/foo/index.json.jscreates a/foo.jsonroute. Everything about this is wantonly confusing. - Generated types are great, but they're hideous to use. A file like
src/routes/foo/[id=integer]@somelayout.sveltemust import its generated types from./__types/[id=integer]@somelayout, and keep it in sync if[id=integer]is renamed to[id=uuid](though after said rename, VSCode will have tried to 'fix' the import, usually to something batshit insane).
Meanwhile, load has a number of confusing aspects:
- It's in the same file as the layout/page component, in a
<script context="module">. This leads many people to ask 'why can't arbitrary components have aloadfunction?', to which the unsatisfying answer is 'you just can't, it doesn't work that way'. The data tree and the render tree are separate-but-related concepts, and our current design conflates them. - Components (other than those layout/page components) can't access the data tree without 'prop drilling'.
- It's rather boilerplatey.
- In its current incarnation, it prevents (or at least discourages) parallel loading, which is bad for performance. We want to make it hard for you to build a slow site.
- One of its arguments is literally called
stuffbecause we couldn't figure out what else to do with it. We thinkstuffis unnecessary, for reasons we'll explore below. - The return value is extremely convoluted — all the possible properties (
error,status,redirect,cache,dependencies,stuff,props) are optional, but some require other properties to make sense (e.g.redirectneeds a 3xxstatus,statusneeds anerrorif it's larger than 400, but anerrordoesn't need astatus) and others don't make sense in combination (e.g.errorandprops). Good luck teaching TypeScript about all these quirks, much less humans. - The
statusanderrorarguments are bizarre.statusis usuallynull, but will have a value if it's an__error.sveltefile of if there's a page endpoint, but also you can return a status code that will override the status code that was passed in, except sometimes it won't, because reasons. This behaviour is confusing enough that I don't think we even have tests for a lot of it, much less documentation. - There's no linkage between the returned
propsand the type of the props in the component itself — you have to redeclare the types, uselessly. (The same goes for page endpoints.)
We don't want to throw the baby out with the bathwater. load has some great qualities — SvelteKit makes it very easy to use external APIs for your data without going back to your server (which costs money for you and time for your users), or to delay navigation until some work has happened without attempting to render, or populating the data tree with things that can't be serialized (like components!) — and these are worth preserving.
But we can definitely do better. The rest of this document will explain how.
Routing
This will seem familiar if you've read #5037, though it has a few small changes.
Instead of defining routes via files and folders, routes are only defined by folders. Layout and page components are defined with +layout.svelte and +page.svelte files — so src/routes/about.svelte would become src/routes/about/+page.svelte.
The + prefix marks the file as being a route file. This scheme has a number of benefits:
- Files without the prefix (like
Widget.svelte) are simply ignored by the router, making colocation easy without workarounds - Route files are grouped alphabetically — in your editor, a route directory will contain child directories, route files, and ordinary files, in that order
- If we need to add more route files in future (e.g.
+loading.svelte), we can do so without it being a breaking change, because+is a reserved prefix
If a page uses a named layout, it can be expressed as +page@layoutname.svelte.
Why +?
We considered a variety of characters (~, @, #, $, %, -, +, =, _ and so on).
Some of these don't work well in some contexts ($ needs to be escaped in the terminal, for example). Others have an existing meaning (_ in particular has a well-established connotation that it makes the thing private, whereas +page.svelte is defining the public interface of your app) that rules them out. @ and # feel very 'heavy', as in they occupy a lot of black pixels.
We ultimately landed on + because you're adding a route to your app (or some behaviour to your route). It's a lightweight, symmetrical character that doesn't have baggage, and doesn't need to be escaped (though vim users will need to do vim ./+page.svelte instead of vim +page.svelte).
As to 'why do we need to prefix route files at all?', see the Prefixes section of this comment.
Endpoints
If a page has an endpoint, it lives in the route as +page.server.js. We will also introduce layout endpoints — +layout.server.js. (We're not using +page.js and +layout.js for this, as they have another purpose described below.)
This bears repeating as it might be unexpected at first: if today you have
src/routes/foo/index.svelteandsrc/routes/foo/index.js, these don't both become+page.{js,svelte}.index.sveltebecomes+page.sveltebutindex.jsbecomes+page.server.js, because+page.jshas a different meaning, covered below.
Standalone endpoints are expressed as +server.js. A +server.js file cannot coexist with +page.svelte.
In future we might retire the use of terminology like 'endpoint' in documentation and elsewhere, in favour of talking about
+page.server.jsand+server.jsfiles
At present, src/routes/foo/index.json.js creates a /foo.json route. Under this proposal, it will be necessary to create a separate route:
src/routes/foo.json/+server.js
The API for endpoints will change, as detailed below.
New load API
As mentioned, <script context="module"> is a problematic home for load. As such, it — along with other module exports like prerender — will move to new files, +page.js and +layout.js, making them siblings of +page.svelte and +layout.svelte. (Error components — +error.svelte — will no longer be able to use load, as this is a source of bugginess and confusion for the sake of an arguably undesirable feature.)
In case you're wondering why we have both
+page.jsand+page.server.js: like+page.svelte,+page.jsruns on the server during SSR and in the client during hydration and navigation.+page.server.js, by contrast, only runs on the server, so it can access your database or filesystem (and private environment variables etc) directly. You can use one or the other (or both together) depending on your needs. The same goes for+layout.jsand+layout.server.js.
Separating everything out into a separate file gives us three big wins:
- It clearly communicates that
loadbelongs to the route and not the component, eliminating the 'why can't myWidget.sveltehave aload?' confusion. - We can reinstate
export const ssr = false. This used to live inside the component, and would tell SvelteKit not to SSR a given page, but in order to readssrSvelteKit had to import the component. Since the most common reason for not wanting to SSR a page was that it used components that couldn't be imported server-side, this blew everything up, and we replaced it with an option inhandlethat is much less ergonomic than a granular, declarative boolean. - It means TypeScript can easily understand your
loadfunction. I'm very excited about this — it's going to be absolutely huge for ergonomics and productivity — but an explanation is out of scope for this section, so for now just trust me.
The function signature will also change.
Inputs
Today, load takes a single LoadEvent argument — an object with url, params, props, fetch, session, stuff, status and error. We're removing stuff, status and error, renaming props to data, and adding three new functions — parent, depends and setHeaders:
stuffprevents layoutloadfunctions from running in parallel with the pageloadfunction.errorandstatusare redundant (and confusing, as described above) —$page.statusand$page.errorshould be used instead.datais a more agnostic and universal term that can be used more consistently throughout the design. In this case it refers to data served from+page.server.js, allowing you to intercept/munge/augment/disregard data from the server.parent(open to bikeshedding) allows aloadfunction to opt back in to the waterfall in cases where that's necessary for the purposesstuffis used for today.depends(again, bikesheddable) allows aloadfunction to declare a dependency on a specific resource (replacingdependencies, which is only necessary in niche circumstances)setHeaderswill give pages the ability to set headers during SSR, includingcache-control. (Unnecessary-for-now but potentially interesting detail: in the future, we plan to introduce a streaming rendering mode that will flush headers and the first bytes of HTML before the page has loaded and rendered. In that mode,setHeaderswould throw an error, since you can't set headers once the response is already on its way to the user.)
If you have both
+page.jswith aloadand a+page.server.jswith aGET, SvelteKit will call theGETfirst and pass thedatatoload. If you only have+page.js, SvelteKit can run theloadimmediately, withdata = {}.
Outputs
Instead of returning a complicated object as described above, load simply returns data:
// src/app/foo/+page.js /** @type {import('./$types').Load} */ export function load(event) { return { // ok, you probably don't need a `load` function for this answer: 42, food: 'potato' }; }
This means we can no longer return redirect, status, error, cache, stuff or dependencies. Let's address these in reverse order.
-
dependencies: ['/foo', '/bar']can be replaced with a call todepends('/foo', '/bar') -
stuffis used for two purposes — to cascade things from the top to the bottom, and to pass things like<title>or breadcrumbs from the bottom of the data tree to the top of the render tree, so it can be used in<svelte:head>or<nav>etc. We'll ignore the latter for now and focus on cascading. Using theparentfunction, we can capture all the data that has been loaded by parentloadfunctions (and+page.server.jsfiles, as appropriate):// src/routes/+layout.js export function load({ fetch }) { const res = await fetch( 'https://www.randomnumberapi.com/api/v1.0/random?min=1&max=100&count=1' ); const [number] = await res.json(); return { number }; } // src/routes/foo/+page.js export async function load({ parent }) { const { number } = await parent(); return { doubled: number * 2 }; }
This prevents the second
loadfunction from running until we've got data fromrandomnumberapi.com. -
Instead of magic
cachevalues, we simply make it possible to set headers directly fromload. Most of the time settingcache-controlheaders on HTML is a mistake, I think, but if you know what you're doing then we will no longer stand in your way. -
There's only one reason to set
status, and that's if something went wrong. Otherwise it should just be 200. Accordingly, under the new rules the way you set an error status is just by throwing an error. We'll expose a helper function from@sveltejs/kit/data(bikesheddable) to allow you to throw errors that have a status code attached:// src/app/admin/+layout.js import { error } from '@sveltejs/kit/data'; /** @type {import('./$types').Load} */ export function load({ session }) { if (!session.user) { // second argument is an optional error message throw error(401, 'show yourself, coward'); } if (!session.user.is_admin) { throw error(403, 'insufficient mojo'); } // ... }
A thrown error that didn't come from the
errorhelper would result in a 500 status code and trigger thehandleErrorhook.With the code above, any
/admin/*route will error if the user isn't signed in or isn't an admin. We don't need to repeat that logic in childloadfunctions, becauseawait parent()will also throw if the right conditions are not met:// src/app/admin/settings/+page.js /** @type {import('./$types').Load} */ export function load({ parent, fetch }) { // this throws if user is not an admin... await parent(); // ...meaning we don't bother fetching this data that would // have been discarded anyway const res = await fetch('...'); // .... }
-
This next one might feel weird but bear with me — redirects are also thrown. That's partly because a redirect result is semantically closer to an error result than to success, but partly because it's useful if
await parent()throws in the redirect case as well as the error case:// src/app/admin/+layout.js -import { error } from '@sveltejs/kit/data'; +import { error, redirect } from '@sveltejs/kit/data'; /** @type {import('./$types').Load} */ export function load({ session }) { if (!session.user) { // second argument is an optional error message - throw error(401, 'show yourself, coward'); + throw redirect(307, '/login'); } if (!session.user.is_admin) { throw error(403, 'insufficient mojo'); } // ... }
Exposing data from load
Assuming we have some data from our load function in +page.js or +layout.js (or from +page.server.js/+layout.server.js if we're getting data direct from the server instead of via load), the next step is to use it in our components.
Today, the component exposes individual props. If you're building an ecommerce site you might have something like this:
<script> /** @type {Array<{ product: import('$lib/types').Product, qty: number }>} */ export let cart; /** @type {import('$lib/types').Promotion[]} */ export let promotions; </script>
But there's a problem here — there's nothing forcing that laboriously-typed cart prop to actually correspond to the type of data the component receives. It could be renamed items, or qty could become quantity. If you're lucky, that'll result in a runtime error you can try and debug, but you're equally likely to ship NaN and undefined to production.
In the new world we have a single data prop, which is automatically typed:
<script> /** @type {import('./$types').Data} */ export let data; </script>
The
./$typesmodule is automatically generated by SvelteKit from your source code — a slightly more sophisticated version of our existing generated types.
Page-level data
The data prop contains only what was returned from load (or +page.server.js, if there's no load). In some cases it's useful for a layout to be able to use data from the page load, for example populating <title> from a single <svelte:head> rather than requiring each page to add <svelte:head> separately.
We can do that with $page.data, which contains everything.
<!-- src/app/+layout.js --> <script> import { page } from '$app/stores'; </script> <!-- it doesn't matter where in the app this component is! --> <svelte:head> <title>{$page.data.title}</title> </svelte:head>
Conceptually, $page.data looks like this:
$page.data = { ...(await load1()), ...(await load2()), ...(await load3()), ...(await loadn()) };
+page.server.js and +layout.server.js API
The Artist Formerly Known As Endpoints also gets an API facelift. One of the rough edges of the current design is that 'page endpoints' and 'standalone endpoints' are very similar-looking but are in fact entirely different beasts — standalone endpoints can return a Response or at least a Response-like object with streaming binary data and custom headers etc, while page endpoints can only return a POJO (Plain Old JavaScript Object) so it can be passed directly to the page for SSR then serialized as JSON to travel over the wire. Also, headers returned from page endpoints are silently ignored, which is weird.
This redesign fixes all that. +page.server.js and +layout.server.js have a different API to +server.js. We'll cover +server.js in the next section.
As with today's page endpoints, +page.server.js exports handlers named for the HTTP methods they handle — GET, POST, PUT, PATCH and DELETE. (We might add OPTIONS in time.) +layout.server.js is not involved in mutations, so it only exports GET.
GET
Like load, a GET handler just returns data, rather than { status?, headers?, body } as now:
// src/routes/foo/+page.server.js /** @type {import('./$types').GET} */ export function GET() { return { answer: 42 }; }
There's only one useful status for a GET request — a 200. Anything else indicates something went wrong, so — as with load — we throw in those cases, using the same helpers as before:
// src/routes/foo/+page.server.js import { error } from '@sveltejs/kit/data'; /** @type {import('./$types').GET} */ export function GET({ session }) { if (session.user?.name !== 'Dan') { throw error(403, `If your name's not Dan, you're not coming in`); } return { answer: 42 }; }
Headers (including cache-control and set-cookie) can be set with the same setHeaders function passed into load.
POST
While +server.js gives you complete control over the Response (see below), in +page.server.js we can streamline things in a way that satisfies the common case and makes it easy to implement progressive enhancement (I hope to resurrect the <Form> discussion soon).
In the case of POST, there are basically three possible outcomes — success, failure due to input validation errors, and failure due to something unexpected that requires showing an error page.
Unexpected failures are easy to deal with, as it's the same as load and GET — throw error(status).
Validation errors cause the page to be re-rendered with the errors, so they can be used for UI that guides the user towards valid inputs:
// src/routes/todos/+page.server.js /** @type {import('./$types').POST} */ export function POST({ request }) { const data = await request.formData(); if (!data.get('description').includes('potato')) { return { errors: { description: 'Must include the word "potato"' } }; } await create_todo(data); }
<script> /** @type {import('./$types').Data} */ export let data; /** @type {import('./$types').Errors} */ export let errors; </script> <form method="post"> {#if errors?.description} <p class="error">{errors.description}</p> {/if} <input name="description"> <button type="submit">Create new TODO</button> </form>
As with today's page endpoints, the
GETfunction will also be called in the case of validation errors, so that bothdataanderrorsare populated when the page reloads.
By default this page will render with a 400 status code, though an optional status property returned alongside errors can override it if necessary.
Redirects
A common pattern with POST requests is to redirect to the newly created resource. We can achieve this in SvelteKit by returning a location property that indicates where the created resource lives:
// src/routes/todos/+page.server.js
/** @type {import('./$types').POST} */
export function POST({ request }) {
const data = await request.formData();
if (!data.get('description').includes('potato')) {
return {
errors: {
description: 'Must include the word "potato"'
}
};
}
- await create_todo(data);
+ const id = await create_todo(data);
+
+ return {
+ location: `/todos/${id}`
+ };
}A native form submission would cause the browser to redirect to /items/${id} with a 303; if no location is provided the page would simply reload (perhaps using the PRG pattern?).
In a progressive enhancement context where you're submitting data via
fetch(either manually, or via a framework-provided<Form>abstraction), you might prefer to get a hold of the created resource rather than reloading the page or redirecting to/items/${id}. In those cases, SvelteKit could match thelocationto a+page.server.jsfile (or callGETin the current+page.server.jsfile) and return the data in the response. We don't need to figure out all the details for this aspect right now, but I'm confident that we're on the path towards an even better progressive enhancement story than we have today.
PUT and PATCH
These methods are basically the same as POST, except that since no new resource is being created, there's no need to return a location property.
DELETE
DELETE is simpler still; no data is being submitted, so there's no room for errors. If the function succeeds, SvelteKit responds with a 204.
+server.js API
As mentioned, +server.js differs from +page.server.js — rather than providing an unserialized POJO to the page for SSR, +server.js is responsible for constructing the entire response. In this new design, we make this responsibility clear — rather than the { status, headers, body } return value, handlers must simply return a Response.
As conveniences (and to keep things consistent), the error and redirect imports and the setHeaders helper can be used, though strictly speaking they're not necessary since you could construct your own error/redirect Response objects with whatever headers you like.
+server.js is useful when you need to expose a public API, or you need to do things that aren't possible with +page.server.js like streaming binary data.
Next steps
We plan to implement this new design (and the migration script) as soon as we're able to, incorporating feedback from this discussion along the way.
I know this all seems like a lot, and you're probably exhausted from reading it. (Imagine how I feel after writing it.) As a result, you might feel like this is a lot of new complexity, just because of the sheer amount of information you've just ingested. In reality, this is a significant simplification, and — when presented in the form of documentation and educational content — will be much easier to learn. I promise.