Why is the SvelteKit backend so good?
| Published: January 29, 2024
| Updated: March 02, 2024
More than 6 years after the initial Svelte release in 2016, the Svelte team released SvelteKit 1.0 which allowed developers to build full-stack applications. It used the Svelte components we all know and love, but was supercharged with an integrated backend and loads of other necessary features.
You could now write server logic that could flow in and out of Svelte components, with helper functions and SvelteKit-specific opinionated processes like load
functions, form actions
and the routing system.
The benefit of coming from a different framework is the perspective to critique design decisions made by the Svelte team.
So in this article, I’m going to walk through the backend features loaded into SvelteKit. I’ll also mention a few libraries and utility functions that will make handling the SvelteKit backend a breeze.
The 3 parts of a SvelteKit backend
The 3 parts of the SvelteKit backend are:
We can simplify this by separating the:
- API endpoints from the
load
functions and formactions
This is because you can think of load
and actions
functions going hand in hand - they go in the same file and can only be accessed by their associated +page.svelte
file.
Differentiating load
functions from actions
(+page.server.ts
)
- The
load
function gets the data prepared for the page and passes it to the client (+page.svelte
), actions
functions are declared so that a client can perform some backend logic when a form on it’s associated page (+page.svelte
) is submitted to it. These are submitted asPOST
requests.
Put simply:
- Get data for a page (e.g. get the friends list of a user) =
load
function - Submit data to a page (e.g. login, or update profile etc.) =
actions
functions
The standard way of setting these functions in a +page.server.ts
file is like this:
export async function load(event) {
// load function contents
return { data };
}
export const actions = {
default: async function ({ cookies, request }) {
// Logic for the default action
return { success: true, data };
},
namedActionOne: async function (event) {
// Logic for another "named" action
return { success: true, data };
} // Continue with as many actions as your need...
};
These 2 functions can only be used for your SvelteKit client. So if you want to make an API that is accessible to anyone (including your client), you’ll have to make API Endpoints.
load
functions
Server load
functions are designed for getting data for a single page, and passing it to the client. That’s why in your router, you’ll share the same parent folder for a page (+page.svelte
), and the load
function (in the +page.server.ts
).
If we wanted to create a load
function for a profile page, then we should have a +page.server.ts
file in a directory like this: src/routes/profile/[id]/+page.server.ts
.
And the load function would resemble something like this:
import { getProfile } from '$lib/utils';
import { env } from '$env/static/private';
export async function load(event) {
const profileId = event.params.id; // This gets the "id" from the url
const profile = await getProfile(profileId, env.DATABASE_KEY);
if (!profile) {
error(400, 'No user exists');
}
return { profile };
}
Remember, because the file name ends in server.ts
, we can also use private environment variables.
When you return the object from the load
function, the type is preserved in the corresponding +page.svelte
file.
So in the +page.svelte
, you will get intellisense for data.profile
, provided that getProfile
returned an explicit type.
Form actions
Server actions
are specifically for passing data from a .svelte
component to the server, as a POST
request.
As a general rule, you call an action from a form, like this:
<form method="POST" action="?/login">
<label>
Email
<input name="email" type="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
<button type="submit">Log in</button>
</form>
Where the action
property in the opening form
tag corresponds to an actions
function in it’s corresponding +page.server.ts
file, which could look like this:
export const actions = {
login: async function ({ cookies, request }) {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
const user = await db.getUser(email);
cookies.set('sessionid', await db.createSession(user), { path: '/' });
return { success: true };
},
register: async function (event) {
// Register the user
}
};
Or for some syntactic sugar, you could change the name of the login
action to default
in the +page.server.ts
file, and remove the action property from the form entirely:
<form method="POST">
<!-- Notice there is no "action" attribute on the form -->
</form>
export const actions = {
default: async function ({ cookies, request }) {
// This "action" is now called "default"
// Login the user
},
register: async function (event) {
// Register the user
}
};
API Endpoints
SvelteKit API endpoints are entirely separate from the load
and actions
functions. The API Endpoints sit in their own file (+server.ts
) and are an island that can be called more generally around the app, or even for developers to use in exernal apps!
When using API endpoints from within your app, you still get the luxury of passing auth automatically, when using relative routing (e.g. /api/update-user
).
An example of an endpoint POST function in a +server.ts
file looks like this:
import { json } from '@sveltejs/kit';
export async function POST({ request }) {
const { a, b } = await request.json();
return json(a + b);
}
where a
and b
would be passed as properties of the body
object in the request.
And if you wanted to build a GET
request:
import { json } from '@sveltejs/kit';
import { env } from '$env/static/private';
export async function GET({ url }) {
const email = url.searchParams.get('email');
const currentDatabase;
const user = await getUserFromEmail(email, env.DATABASE_KEY);
if (!user) {
error(400, 'No user found with that email address.');
}
return json(user);
}
And you would get the request parameters through url.searchParams.get()
.
You would’ve also noticed that in our +server.ts
files, we also have access to private
environment variables.
As a general rule, if the file name ends in server.ts
, you’ll have access to those secrets, whereas everywhere else you’ll only have access to public
environment variables.
If you’re interested in learning more about SvelteKit endpoints, there’s an article I wrote to guide you through building API’s with permissions and all sorts of other useful information.
Now that we’ve covered all 3 components of a SvelteKit backend, let’s start critiquing some of its properties.
Are Form Actions Type Safe?
Unlike load
functions, server actions
and API endpoints are not type-safe going in or out. So, when pushing data to an action, you have to make sure you’re pushing the right data in from your +page.svelte
component file.
Commonly, developers use a library like Zod to validate the data you’ve receive in your backend functions to make sure it’s of some expected shape, like this:
import { z } from 'zod';
import { fail } from '@sveltejs/kit';
const schema = z.object({
email: z.string(),
password: z.string()
});
export const actions = {
login: async function ({ cookies, request }) {
const form = event.request.formData();
const object = Object.fromEntries(form.entries());
const { success, data, error } = schema.safeParse(object);
if (!success) {
return fail(422, { object, errors: error.issues });
}
const user = await db.getUser(email);
cookies.set('sessionid', await db.createSession(user), { path: '/' });
return { success: true };
} // other actions...
};
Whilst we can validate the shape of the form coming in and error if it doesn’t match, we still don’t have the experience of calling as you would in a regular function with an expected argument type.
For example, in your .svelte
components, if you were to call a component-scoped function, your IDE would give you intellisense for the function arguments, and error if there is the potential of a mismatch in types.
For example, when calling a function, you know exactly what should go in, and the type of it:
And if there’s a possibility of an error, the language server will inform you:
To re-create this as a server function, we’ll need to move the addNumber
function into a different file (POST
endpoint in a +server.ts
for this example), and add the arguments to the body.
export async function POST(event) {
const { a, b } = (await event.request.json()) as { a: number; b: number };
const result = a + b;
return json(result);
}
Firstly, instead of declaring the expected type of the function, in order to get types for a
and b
, we need to assert that it will be:
{
a: number;
b: number;
}
As mentioned earlier, you can use Zod to verify this, but for the sake of this demonstration this is not important.
And then when calling it from anywhere in our app, we’d need to add the arguments to the body
of the fetch
call:
Notice that unlike the regular function example, we don’t have any intellisense of what we’re expecting on the other end of the function (fetch
) call. No direction for what arguments are expected, or their type.
So, you could pass in anything and the language server will be okay up until the point of testing it:
Here, we passed in b = 'Hello'
, and there’s nothing stopping us or notifying that there’s an issue. And to add to that, the result we get back from the endpoint is also not typed.
It’s relatively trivial for a case as simple as this, however when you’re dealing with large functions and inputs that have been passed in as props from 2 levels up that can take multiple types, it helps to be hyper-vigilant of the data being passed around your app.
And when you allow for anything to be passed in to a server function:
- It’s slower to create (as there’s a lack of intellisense), and
- You can unknowingly introduce bugs through type mismatches.
This is especially true when you need to make modifications to your server functions, and perhaps somewhere in your codebase you forgot to update calling it - you’ve just unknowingly introduced a bug and the build stage will let it slide. If the language server can catch a bug at build time, you’ll have a safer app, and you’ll work faster when developing it.
How has the Svelte team responded to having no typesafe POST
requests
Until very recently, this behavior was the norm and it was accepted by Node developers. The backend team would create docs like swagger to address expected types for backend functions, and everyone should abide to the specification and assume it’s correct.
But for solo developers who want to build quickly and make regular backend changes, leaning on the typescript language server to provide guardrails is heavenly.
Frameworks like Solid Start and Qwik have the server action typesafety built in natively, so the feature was suggested in a GitHub issue. In response, the Svelte creator Rich Harris responded with the following:
There’s no such thing as type safety when dealing with client-server communication.
Which is true.
Given that actions are plain old POST
requests with some syntactic sugar, it makes sense that they aren’t type safe. Anyone can theoretically POST
anything to some endpoint.
But that doesn’t change the fact that there is a glaring opportunity to be had here to improve the developer experience.
For a number of valid reasons (discussed further down in the thread), the Svelte team effectively rejected the idea of closing the gap between front-end calls to the back-end, where developers could access intellisense and have types returned back from server actions
and endpoints.
So, instead of relying on this feature to be built in natively to SvelteKit, there are a few libraries you can use to bring typesafety to the front-end from the backend for POST
requests.
How to add typesafety to backend communication in SvelteKit
So far, we’ve touched on all of the building blocks of a SvelteKit backend. We’ve also discussed the pain-point of lacking typesafety between front-end components and backend functions in server actions
and API endpoints.
So in this section, we’re going to dive deeper into this problem and suggest a few ways to upgrade your SvelteKit backend experience with a external libraries.
The best and most popular options you have now for bringing typesafety from the backend to the frontend is:
Superforms
Superforms leans on Zod and the existing native typesafety from load
functions to introduce typesafe interactions between frontend .svelte
components and server actions
.
With Superforms, you’ll declare the expected schema of a page’s submitted form as a Zod object:
const schema = z.object({
email: z.string(),
password: z.string()
});
And then pass the schema into your page’s load
function:
import { z } from 'zod';
import { superValidate } from 'sveltekit-superforms/server';
const schema = z.object({
email: z.string(),
password: z.string()
});
export async function load() {
const form = await superValidate(schema);
return { form };
}
So now in your page, the data.form
property is loaded with a bunch of types that will help you to build out your forms in the .svelte
component.
<script lang="ts">
import type { PageData } from './$types';
import { superForm } from 'sveltekit-superforms/client';
export let data: PageData;
const { form } = superForm(data.form);
</script>
<form method="POST">
<label for="email">Name</label>
<input type="text" name="email" bind:value={$form.email} />
<label for="password">Email</label>
<input type="password" name="password" bind:value={$form.password} />
<button>Submit</button>
</form>
The form in your component now sits a lot closer to the data you’re expecting in the backend. Now, when you submit, you have simple data validation in your action
// ... imports, schema and load function
export const actions = {
default: async function ({ request }) {
const form = await superValidate(request, schema);
if (!form.valid) {
return fail(400, { form });
} // Do something with the valid form.data
return { form };
}
};
So this is a great library to handle form validation, and to pinpoint errors and have them respond accordingly on the form fields in the component.
A particularly useful feature is being able to validate the shape of the form submission before it gets sent to the server. There’s so many features this library has in attempts to close the gap between calling server actions
, and
The limitation with Superforms, however, is that it only addresses how to more easily handle forms, and doesn’t provide a better approach for calling API endpoints.
When you call an endpoint, you still have to use fetch
, and the body
parameters still lack typesafety or intellisense.
This is where tRPC comes in.
tRPC
tRPC allows you to build and consume typesafe API’s, which can be shared between a client and a server without schemas or type generations. This means that any type errors when calling API’s will be caught at build time, and the language server will notify you as your develop.
It does this by providing a statically typed router which can run query
or mutation
procedures (functions) on your server. As the router is statically typed, you can export it’s type, and use it to initialize a special helper function which you can call on the client, and it takes in the exported type of the server tRPC router.
In summary: You can call functions that run on your server (and have access to private environment variables) from the client as if they were ordinary client functions.
There’s no need for fetch
calls - you just need import the helper function into your client, and then call the specific procedure (function) on it.
A simple query
call in your .svelte
frontend component would look something like this:
const user = trpc($page).getUser.createQuery({
userId: $page.params.id
});
And a mutation
call would look like this:
<script lang="ts">
let name: string;
let email: string;
const addUser = trpc($page).addUser.createMutation();
async function addUserToDb() {
const { addedUser } = await $addUser.mutateAsync({
name,
email
});
console.log('This user was added', addedUser);
}
</script>
<input bind:value={name} />
<input bind:value={email} />
<button on:click={addUserToDb}>Submit</button>
And with that, you get a typed response for user
in your query
as you normally would from a load
function, but the biggest benefit is in the mutation.
When calling the addUser
procedure in the .svelte
component, you get full intellisense to assist when constructing the call, and the compiler won’t let you build unless any typing errors are fixed.
In your IDE, the intellisense would look something like this:
For example, with the example above, if name
and email
was required in the addUser
server procedure, the compiler would not let this fly, as we haven’t yet checked to see if they exist when clicking the Submit button.
How to use tRPC with SvelteKit
There’s no official support for SvelteKit from the core tRPC team, however the alternatives are:
- Using the vanilla client for Node, or
- Using the tRPC-SvelteKit library (unofficial), or
- Using the tRPC Svelte-Query adapter if you’re already (or would like to use) Svelte Query
I’d recommend using either of the last 2 options, as they are both built on top of the vanilla tRPC client, specifically for Svelte. They also provide convenient writable stores for using the procedures in our .svelte
components, and the integration documents are more straigtfoward than having to figure it out yourself with the basic vanilla client.
The setup process is more involved than most libraries, so you’re best following the docs on the specific library you go with.
It should take you no more than 10-15 minutes to get started, and then once you’re set up, you get all of the benefits of a typesafe communication layer between your frontend, and the endpoints in your router.
Conclusion
We’ve discussed a lot about the moving pieces with the SvelteKit backend, and how to best leverage it. Whether that comes from introducing additional libraries to jam typesafety into our endpoints, or to just have it work better with the forms on our frontends.
The SvelteKit backend is opinionated enough to make processes like the load
function a joy, but flexible enough to run as a vanilla backend.
The main criticism that could be warranted of the current SvelteKit backend is the lack of typesafety when communicating to it from the frontend. Where other meta-frameworks have sometimes opted into the possibility of sharing types to and from server actions, SvelteKit has not done so, in favor of letting the developer have control this.
Now it’s time to take what you’ve learned from this article and go build an API.
Hope you learnt something in this article! ❤️