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.

When developing a backend with Sveltekit

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.

Skip to FAQ’s

The 3 parts of a SvelteKit backend

The 3 parts of the SvelteKit backend are:

  1. load Functions (+page.server.ts),
  2. Form actions (+page.server.ts), and
  3. API Endpoints (+server.ts)

We can simplify this by separating the:

  • API endpoints from the
  • load functions and form actions

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.

via GIPHY

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 as POST 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...
};

via GIPHY

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.

via GIPHY

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
	}
};

via GIPHY

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.

via GIPHY

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.

via GIPHY

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:

Intellisense from the IDE when adding arguments to a functin

And if there’s a possibility of an error, the language server will inform you:

An error when the wrong type has been entered into the function

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:

There's no intellisense when adding body parameters to a 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:

You can pass incorrect types to your fetch body and the compile is happy with 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.

via GIPHY

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:

  1. It’s slower to create (as there’s a lack of intellisense), and
  2. 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.

via GIPHY

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.

via GIPHY

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.

via GIPHY

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.

via GIPHY

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:

  1. The Superforms library for your forms and server actions, and
  2. tRPC for all server functions

Superforms

Superforms leans on Zod and the existing native typesafety from load functions to introduce typesafe interactions between frontend .svelte components and server actions.

via GIPHY

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.

via GIPHY

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.

via GIPHY

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:

You get intellisense when calling a server procedure from the front end with tRPC

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:

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.

via GIPHY

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.

via GIPHY

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! ❤️

FAQ’s

Thanks for reading ❤️

Here are some other articles I think you might like!