How does SvelteKit Fare as a SPA Framework?

  • Henry Lie
    Frontend Team Lead at HENNGE

When people hear metaframework, the first thing they think of is usually SSR. However, SvelteKit also supports other rendering strategies like CSR and prerender. As the recommended way to build any Svelte app, is it going to offer the best DX for all its use cases? In this talk I'll share my experience and tell you what's good, what's bad, and what's awesome about building SPAs with Sveltekit.

Transcript

Hi, everyone.

Today, I'd like to share my experience answering the question, how does VELT get fair as a SBA framework?

Let's get started.

First of all, my name is Henry.

I'm currently living in Tokyo, working at a company called Henge.

I'm leading a front-end team building cloud security solutions.

And in the beginning of the journey, we have our company.

We have several product teams using either React or Vue for their front-end.

And I feel like in these projects, they've grown to be very complex very quickly.

We have a lot of state being passed around either in stores or inside components.

Sometimes it can be hard to track how the data flows in these projects.

And we suddenly have a new project.

And this project, we need to build a front-end around it.

So I decided to pitch VELT to the team.

And they agreed to try it out.

And you might be wondering why I decided to pitch VELT, even though we already have React and Vue knowledge.

And I'm a big proponent of writing less code.

I believe with less code, we type less.

We test less.

We review less code.

And when we go back to the code again in the next month or in the next year, there's less overhead in trying to understand what the code is trying to do.

VELT also helps us with this by offering some nice shorthands, like in this case, variable name as attribute can be passed in with curly braces.

VELT also supports curly braces when trying to pass in this variable as class name.

And also, it can pass the Boolean here to know which class that we want to attach to this element, which is very nice.

And VELT also comes with a lot of extra goodies, like it doesn't use virtual DOM, so performance is good by default.

And CSS are also scoped by default, and it can report if you are not using a style that you have already declared.

It gives you accessibility hints if you forget to, for example, attach alt attribute to your image text.

The store contract is much, much simpler than what the other framework has.

And it also is a first-party store, so everyone can know how to use it immediately instead of having to import a different library.

VELT also tries to be as close as possible to native APIs.

So it feels like you're almost working with vanilla JavaScript, but there are some VELT reactivity added on top, so you can keep the component declaration very tidy and declarative.

And so we decided to build an SPA with VELT.

And by SPA, we mean there is no server being included in the entire infrastructure.

Assets will be served as static files.

And we use API for communication with the backend in order to fetch data from database.

And authentication is done via JWT tokens.

And you might be asking, "Why SPA?

" Well, everyone everywhere has been recommending to use SSR.

So in our company, we have a slightly different requirement than most projects.

We're building a SaaS product for other businesses.

We sell products, we sell licenses to businesses, and their employees will need to log into our product in order to start using it.

So we don't have any public-facing page, and we don't need to care so much about SEO.

We build dashboards with high interactivity.

When a user visits a page and it gets loaded, they likely need to do a lot of stuff in it.

So SPA is a good fit because SPA by default needs JavaScript to support interactivity.

Specifically for this project, the backend API is already in place.

The development started earlier, so when I joined the project, they already have an API ready for consume in the frontend.

And also with the existing knowledge of the team, it's much easier to still use the SPA methodology rather than introducing SSR.

We'll need to create a new frontend server, we need to connect it with the existing systems.

And also for the frontend engineer side, we also need to start learning how to run code on the client, how to run code on the server, where is the best place to do one of them, where is the best place to do the other.

This kind of decision that we don't have yet can cause the project to get delayed.

So we decided to just use SPA for the time being.

And you might ask, why SvelteKit then?

Aren't meta frameworks like SvelteKit mainly for SSR?

And I'm quoting the SvelteKit announcement blog post here for version 1.

0.

As of the release of 1.

0, it's the recommended way to build Svelte apps of all shapes and sizes.

And that means SPA is also included.

And SvelteKit is actually very flexible in this regard.

It supports multiple rendering strategies, SSR, CSR, pre-render.

You can also mix and match them for different routes, which is, I think, a very powerful strategy.

It also comes with opinions on how to do common tasks that we often do in web development, like how to structure our project, how to do routing, how to fetch data on those routes, and also how to handle errors.

Opinionated default, I think, is a very good thing.

It reduces fragmentation to a minimum in a team, even in Svelte community.

If there is only one way to do the correct thing, then nobody will get confused.

If there are, like, two or three ways to achieve the same thing, people might be confused which one should we take.

And people might have different preferences, which could slow down the team for the long run.

People can still customize parts that they don't like if they have a different opinion, but the default helps everyone knows what is, like, the recommended approach from the Svelte team.

So directory structure is one of the things that most other SBA frameworks not give an opinion of.

In our company, we are divided on which one is the best way to structure our directories.

Some teams use aramid design.

Some people like it.

Some think it's a bit too confusing because it uses a lot of technical terms.

It's a bit open to interpretation how big a component is.

Some others use a very simple one, which is just by co-locating components that belong in the same place.

Admin dashboard, user dashboard, shared between both.

This approach worked well for small projects, but it really doesn't scale.

It makes it really hard to tell which components are supposed to be rendered on the route level and which ones are supposed to be rendered as the children of those route components.

And also data structure and data flow becomes really confusing as a result.

In SvelteKit, they decided to solve this issue by also incorporating routing system into the directory structure.

So we have src_routes, which is where all of the route level pages live, and they are living inside a folder.

So in this example, we have a folder called admin.

We render a page.

svelte component here so that when the user visits your domain/admin, it will go to the admin folder and render the page.

svelte inside that folder.

If you want to have another nested route, like admin settings, for example, then you simply put a folder called settings inside the admin folder, and you put the page.

svelte file there.

You can also declare a separate file called page.

ts.

I'm going to go back to this file later to explain about load functions.

And in the folder level, you can also declare layouts.

Layouts is very useful if you want to have like part of the UI that stays between pages in the same folder.

An example for this would be like a sidebar in the dashboard.

You want the sidebar with the navigation links to always show no matter where you are.

So that's a good candidate to have the layout component.

And you can also declare load functions for this kind of layout too.

You can also declare an error.

svelte component.

This will act as a fallback.

So if an unhandled error occurred, SvelteKit will try to find the nearest error.

svelte file and render it in place of the page.

So you can have a very low class error that still keeps all the layout properties.

If you, for example, want to have this kind of categorization without actually using it in the URL, you can wrap the folder name in parentheses.

In this case, for example, I want the domain, the root path in the domain to be what is the user dashboard.

So instead of having to create a folder called user and serving it in /user, I can just wrap it in parentheses.

And this will not be part of the URL, but SvelteKit will still allow us to co-locate components together and also nest other routes there if necessary.

Finally, we have srclib, which is where we put all our reusable modules, store definition, utility functions.

SvelteKit also provides an easy way for us to import from here.

So this is an example of the SvelteKit documentation page.

The part highlighted in green are ones that you always want wherever you are.

No matter which page you visit, you always want the top bar and the side bar.

So these can live in the layout files.

The part highlighted in blue will be the page part.

So when you have a +page.

svelte inside the introduction folder, this is where you will declare all the UI.

And if, for example, an error occurred inside the introduction component, you can render an error.

svelte component that is nearest to that page file.

So that in this case you can see the error fallback is localized.

You can still keep the sidebar and keep the top bar, but you can have the error page here and you can, for example, ask the user to reload.

And it will only reload the part that is responsible for handling this area, which is the page.

svelte part.

So data fetching is also another thing that SvelteKit does really well.

First, when I saw Svelte, they have the await block and I think this is very powerful.

You define a promise and you want to wait for it.

And while waiting for the promise to resolve, you can render something.

After it is resolved, you can render another thing.

And you can also render a completely separate thing if the promise is rejected.

This looks very powerful, but in SvelteKit, you don't even need to use this block in most cases.

That's because SvelteKit has the concept of load functions.

Load functions provide clear separation between route dependency fetch and interactivity fetch, like, for example, from navigation.

Navigation will be suspended until the promises that is returned by the load functions resolve.

It works both on the page level and on the layout level.

And all the results of load functions will be exposed to the route components as props.

All the load functions returned from the ancestral layouts will be merged in, so you can get, like, a nested data based on your directory structure.

Best of all, SvelteKit types this for you automatically.

You don't have to type this manually, even if you decide to change what your load functions return.

This is how you declare a load function for your routes.

So, in this example, I have a route called /items, and I want to render a list of items that I fetched from the API.

So, first, I create a file called +page.

ts.

This file will export a load function, and this function accepts fetch from SvelteKit.

It uses that fetch to call the endpoint, which is in this case /ips/items, and just return the result.

And while going to navigate to /items, SvelteKit will first call the load function, wait until the load function resolves, and only then will it start rendering the next component.

This makes it so that the page.

svelte component doesn't need to care about showing any loading indicator.

The loading is already awaited by SvelteKit, so it can focus on only rendering the result, and this leads us to a very small and concise component.

It only has one thing to do, which is only to render the happy path.

And it will accept the result from the load function from the let data prop here.

Another nice thing from SvelteKit is that it has a preloading functionality.

It makes navigation feel instantaneous by eagerly loading root dependencies.

It does this by trying to guess when the user will want to visit the link, which is by listening to the hover event on links or touch start events if they are on mobile devices.

And it will also honor the user's preference, so if they have carrier data set up and they don't want to use more data than necessary, then SvelteKit will not enable this feature.

Best of all, you don't need to set this up.

Everything is done by default.

And let me show you how this works.

So if we go to the network tab here, and I start hovering over this link, you can see that there is a request being made, and when I click on this link, it's instantaneous because all the dependencies has been loaded.

Same with here, here, and if we make the network slightly slower, we will see that navigation will be delayed for a little bit.

But in this case, the navigation is still finishing within a reasonable time frame, so we don't need to show any loading indicator yet, and only if we throttle it even further, so in this example, it will load really slowly, and only in this case SvelteKit will start showing the progress bar on top.

This is a very good UX because you don't want loading indicators that show up and disappear in a short period of time.

The user will only see it as a flash of items, and it's not a good user experience.

By waiting until some time has passed before starting to show it, the UI will feel like it's instantaneous by default and only fall back to loading indicator when it's absolutely necessary.

The best thing of all is it's not that hard to reproduce what SvelteKit has with the progress bar.

In this example, we have the root layout.

svelte component.

In this component, we can listen to the navigating store that SvelteKit exposes, and if navigation is in progress, we can render an element.

In this case, I'm rendering a span instead of a progress bar, but instead of rendering the span immediately, I add the delay by 1 second by utilizing Svelte's transition helper.

This allows us to achieve the same effect as the SvelteKit documentation.

So I've talked about a lot of the good stuff of SvelteKit.

Is there any issue that I found along the way?

So, the first one that I thought of is not really an issue, but reading through the documentation, it can be hard to know which cases apply to SPA and which ones only apply to SSR, for example.

An example would be loot functions that is provided by SvelteKit also provides a fetch function.

This fetch function is similar to what the window.

fetch provides you, but SvelteKit patches it with some features that seem to be only useful for SSR.

So you might be left wondering, is it still important to use this function provided fetch?

The answer is actually yes, because other than all the SSR goodness, SvelteKit also allows us to invalidate the return value from the loot function easily by invalidating the URL that we call in that fetch function.

Second one is debugging load functions can be tricky.

Load functions rerun on several conditions, but everything happens under the hood, so it can be hard to track any issue that happens.

I have an example where I'm trying to debug an issue on the root layout load function, where it is getting called too often.

Every time I hover over any link, the root layout load function is getting called as well.

I spent some time reading the docs again, making sure that my understanding is correct on when the load function would rerun.

But after some time I found that this is actually not an issue in our project, but it was because of a third-party plugin that we used that introduces this bug.

This kind of thing might not happen often, but when it does, it can be hard to debug.

Another issue we had is when we want to patch fetch.

We want to automate repetitive tasks that we do with fetch, like attaching the token to the authorization header, attaching the content type if it's necessary, convert between the API convention snake case to JavaScript camel case, and throw errors automatically when the response is not successful.

SvelteKit also patches fetch, but it only keeps the reference internally, and it only provides it to us in load functions.

So in order for us to be able to patch fetch, we need to do it before SvelteKit has the chance to patch fetch the first time.

So in this example we have the app.

html that SvelteKit gives you.

There's a section called sveltekit.

body, and we need to add a script before sveltekit.

body, so that this script will be executed before the sveltekit.

body part.

In this example, I'm adding a reference to the middleware component that I declared in the assets folder.

And this is what the middleware contains.

This is a very simplified example, but we wrap everything in IFE first.

Then we declare a proxy around the fetch function, and we replace the global fetch with the proxy version.

And as the proxy configuration, we declare the apply method, which is going to be called when the fetch is getting called.

It provides several arguments, and the argument will follow the argument for fetch.

So the first argument would be the URL, the second argument would be the options.

And then we can patch the arguments as necessary.

In this example, I'm fetching the token from the local storage, and I attach it as the option.

headers option under the authorization name.

Once all the patching are done, we can call the target function, which is the one that is exposed by the apply function, which will call the internal implementation of fetch that this is currently proxying.

But we are passing in the arguments that we have patched.

So fetch will be called with the authorization token automatically added to the header in this case.

So, this approach worked well for us, but there are some drawbacks to this approach.

The first one is you cannot use ESM syntax like import, because you cannot use the script type module.

Script type modules will be deferred by default, so if you want to use it, then it will be called a bit later after the file kit has started initialization.

And the code is isolated from the main code base.

That means like if you want to have the middleware in TypeScript, for example, you need to make sure to have its own pipeline to compile TypeScript to JavaScript.

You also need to rely on some browser stuff to communicate with the main code base, like the local storage, or just throwing errors.

We cannot use, like for example, file kit stores to do this.

It's also harder to track errors in general in this case, because it's so isolated from the main code base.

So, alternatives that we have considered, one of them is ServiceWorker.

ServiceWorker is not trivial to set up.

You can accidentally have ServiceWorker hanging forever with stale data, but it can also be very powerful if set up correctly.

There are some libraries that can help us with this, like the fitbwa plugin, the msw, but there are still some issues which is like, ServiceWorker is still isolated from the main SvelteKit code base, and you cannot access local storage with ServiceWorker, which limits its usefulness.

There are a proposal, another approach that would be great if we can make this work, which is using the handle-fetch hook.

Right now, the handle-fetch hook only exists for server-side hooks, but it would be great if SvelteKit can provide a similar hook for the client-side hooks.

Patching fetch, even with SSR enabled, is still something that can be very useful to automate tasks, like attaching the header automatically, like the earlier example, and also throwing errors if fetch is not successful, it's not returning the 200 or 300 status codes.

There is an open issue on the SvelteKit issue on GitHub that is talking about this proposal, so feel free to go there and upvote the issue if you also think this is beneficial.

I also want to mention a bit about caching.

First of all, a reminder that caching is easy to get wrong, and it's better to not cache anything at all, rather than caching things incorrectly.

But with that said, there are a few strategies that we can take.

The first one is using a wait parent.

A page layout function can wait for all the ancestor layout load functions to complete, and receive a merge combination of all the data that all those load functions return.

With this, we can have some sort of cache, where the child page can rely on the data that is already fetched by the parent routes, and we need to take care not to introduce request waterfall.

This is more often done by a wait chaining, instead of running the promises at the same time.

So, there are two things to keep in mind.

The first one is only a wait parent if you really need to get the ancestor data.

For pages that don't really need this data, don't use a wait parent.

Your load functions will return much more quickly.

And if you do need to wait parent, and you have another promise at the same time, for example, always remember to use promise.

all, so that both promises will run at the same time.

Another way is to use Felt's writable store.

This store is very simple and very powerful.

It's a bit more manual, but it can be useful if you need to aggregate data on the client side.

For example, you have data from multiple endpoints that you want to aggregate before showing to the user, store would be a good choice.

And lastly, you can also use ServiceWorker to cache responses.

So, you might be wondering like, what if we go SSR?

Will that help?

I think it's not strictly necessary, but there are some potential benefits.

The first one is with SSR, we can inline initial data straight in the HTML.

An example would be feature flags.

You might want to hide features from certain users and with server-side rendering, you can contact the feature flag API directly in the server, get what the user should be having and send what the user will have directly in the HTML.

With SPA, you need to first download the empty HTML, do a fetch, check what the user can access, and then update the page.

This will cause layout shifts.

Or, in SPA, we can usually alleviate this issue by using splash screens, but none of these are a good user experience.

The server-side part will actually give you a better experience because the user will get what they want to see immediately.

Some tests are also just simply better handled on the server.

The first part is the data aggregation that I mentioned earlier.

It would be nice if we can aggregate everything in the frontend server so that the frontend server only needs to send one response back to the browser instead of the browser sending multiple requests and receiving multiple responses.

The server can also be used to add cache control headers so that the server can control how long a resource should live in the user's browser.

All of this on top of the benefits that SSR give you in general, which is performance and resilience.

So, in conclusion, how does SvelteKit fare as a SPA framework?

I think it works really well.

SvelteKit really brings the joy back to coding frontend for me like no other framework gives.

There are some hiccups on the way, but the benefits that we have far outweigh the difficulties that we encounter.

That's it.

Thank you for watching.

Hope you find this useful, and enjoy the rest of of Svelte Summit.

See you.

 

Svelte Summit is a conference made possible by Svelte School AB and Svelte Society.