Fixing `load`, and tightening up SvelteKit's design before 1.0 · sveltejs kit · Discussion #5748

20 min read Original article ↗

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.svelte dictate how those routes behave
  • moves load into a separate +page.js file along with export const prerender etc
  • adds page-level export const ssr = false control
  • radically simplifies the load API
  • improves SSR and navigation performance by allowing load functions to run concurrently
  • simplifies endpoints
  • replaces page props with a single data prop, plus an errors prop for validation errors from POST/PUT/PATCH handlers, 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.svelte and src/routes/foo/index.svelte are equivalent, and having two ways to do things is always a source of confusion. Each has downsides — too many index.svelte files open in your editor gets confusing, but foo.svelte makes it awkward to colocate related files. I frequently find myself moving foo.svelte to foo/index.svelte as 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/Widget route. It's possible to mark files as 'private' with config.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.svelte and __error.svelte are just... ugly.
  • A file like src/routes/foo/index.json.js creates a /foo.json route. 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.svelte must 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 a load function?', 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 stuff because we couldn't figure out what else to do with it. We think stuff is 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. redirect needs a 3xx status, status needs an error if it's larger than 400, but an error doesn't need a status) and others don't make sense in combination (e.g. error and props). Good luck teaching TypeScript about all these quirks, much less humans.
  • The status and error arguments are bizarre. status is usually null, but will have a value if it's an __error.svelte file 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 props and 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.svelte and src/routes/foo/index.js, these don't both become +page.{js,svelte}. index.svelte becomes +page.svelte but index.js becomes +page.server.js, because +page.js has 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.js and +server.js files

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.js and +page.server.js: like +page.svelte, +page.js runs 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.js and +layout.server.js.

Separating everything out into a separate file gives us three big wins:

  • It clearly communicates that load belongs to the route and not the component, eliminating the 'why can't my Widget.svelte have a load?' 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 read ssr SvelteKit 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 in handle that is much less ergonomic than a granular, declarative boolean.
  • It means TypeScript can easily understand your load function. 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:

  • stuff prevents layout load functions from running in parallel with the page load function.
  • error and status are redundant (and confusing, as described above) — $page.status and $page.error should be used instead.
  • data is 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 a load function to opt back in to the waterfall in cases where that's necessary for the purposes stuff is used for today.
  • depends (again, bikesheddable) allows a load function to declare a dependency on a specific resource (replacing dependencies, which is only necessary in niche circumstances)
  • setHeaders will give pages the ability to set headers during SSR, including cache-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, setHeaders would 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.js with a load and a +page.server.js with a GET, SvelteKit will call the GET first and pass the data to load. If you only have +page.js, SvelteKit can run the load immediately, with data = {}.

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 to depends('/foo', '/bar')

  • stuff is 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 the parent function, we can capture all the data that has been loaded by parent load functions (and +page.server.js files, 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 load function from running until we've got data from randomnumberapi.com.

  • Instead of magic cache values, we simply make it possible to set headers directly from load. Most of the time setting cache-control headers 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 error helper would result in a 500 status code and trigger the handleError hook.

    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 child load functions, because await 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 ./$types module 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 GETthrow 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 GET function will also be called in the case of validation errors, so that both data and errors are 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 the location to a +page.server.js file (or call GET in the current +page.server.js file) 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.