svelte
One week with Svelte

Last July I spent a week with Vue and wrote it up. The short version was that Vue felt like a framework in the sense a backend developer means the word, and that Vite and Vitest were the real story. I ended by saying Vue would be my first choice for small tools.
Several people told me that if I liked Vue for those reasons, I should try Svelte. So I did the same thing again: a weekend to learn it, the evenings in between, and this weekend to finish and write it down.
The same caveats apply. I am a backend developer. I like frameworks, I like having decisions made for me, and I am not much of a CSS person. I have shipped Angular and React, and now I have written one small Vue application. To make the comparison fair, I rebuilt exactly the same thing: the admin page for the flat automation - devices and their state, the rules with an enabled toggle, a form to edit one rule.
This is Svelte 4.2 and SvelteKit 2, with Vite 5 and Vitest 1.3, as of the start of March 2024. Svelte 5 is in public preview and changes a lot; I come back to that at the end.
Starting took one command, again
npm create svelte@latest flat-admin
The questions are familiar from create-vue: a skeleton or a demo app,
TypeScript or JSDoc or neither, ESLint, Prettier, Playwright, Vitest. The result
is, strictly, a SvelteKit project rather than a Svelte one. SvelteKit is to
Svelte what Nuxt is to Vue or Next.js is to React: routing, server rendering,
data loading, building and deployment. The difference is that with Svelte it is
the default way in, not an optional layer on top.
npm run dev started in under a second, which is no longer surprising, because
it is Vite. SvelteKit has been built on Vite from the start, and SvelteKit 2,
released in December, moved to Vite 5. When I wrote in July that Vite was going
to be bigger than Vue, this is the kind of thing I meant: my second framework in
eight months, and the dev server, the config file and the plugins all felt the
same.
A component is mostly just HTML
The device list from the Vue version, in Svelte:
<script lang="ts">
import type { Device } from '$lib/types'
export let devices: Device[]
let query = ''
$: visible = devices.filter((d) => d.name.toLowerCase().includes(query.toLowerCase()))
</script>
<input bind:value={query} placeholder="Filter devices" />
<ul>
{#each visible as device (device.id)}
<li class:off={!device.on}>{device.name}</li>
{/each}
</ul>
<style>
li {
padding: 0.25rem 0;
}
.off {
opacity: 0.5;
}
</style>
It is the same three blocks as a Vue single-file component - script, markup,
styles - with less in each of them. There is no <template> wrapper. There is no
ref, no computed import and no .value. A prop is a variable with export
in front of it. State is a let. Styles are scoped by default, without having to
ask.
The line that does the work is $:. It is a JavaScript label, which Svelte
borrowed for its own purpose: "rerun this statement whenever anything it reads
changes." visible is recomputed when devices or query change, and not
otherwise - the same thing as Vue's computed, in one character.
The reason this works is the part of Svelte that took me longest to appreciate:
it is a compiler. React and Vue ship a runtime to the browser and work out
what changed while the application runs. Svelte reads your components at build
time and turns them into plain JavaScript that updates the specific DOM nodes
that depend on each variable. There is no virtual DOM and very little framework
code in the bundle. The compiler knows visible depends on query because it
read the source.
The server side is where I was won over
I did not expect to write this section, because this is where the backend developer in me got interested.
In the Vue version, the browser called the flat's API through Vite's dev proxy,
and I had a small fetch wrapper and a Pinia store to hold what came back. In
SvelteKit, each route is a folder, and a page can have a server file next to it
that runs only on the server:
// src/routes/devices/+page.server.ts
import type { PageServerLoad } from './$types'
import { listDevices } from '$lib/server/flat'
export const load: PageServerLoad = async () => {
return { devices: await listDevices() }
}
The page receives whatever load returns, fully typed - the ./$types import
is generated from the folder structure, so if I change what load returns, the
page's types change with it:
<script lang="ts">
import type { PageData } from './$types'
import DeviceList from '$lib/DeviceList.svelte'
export let data: PageData
</script>
<DeviceList devices={data.devices} />
That removed the store and the fetch wrapper, because the only thing that
talked to the API was server code. It also meant the API token never went near
the browser. SvelteKit is strict about that in a way I appreciated more than any
other feature this week:
- Environment variables come from
$env/static/privateor$env/static/public. Import a private one into anything that ends up in the browser, and the build fails. - Any module under
$lib/server/, or named*.server.ts, can only be imported by server code. Import it from a component and the build fails.
In the Vue article I praised Vite for only exposing VITE_-prefixed variables to
client code. That is a naming convention. This is the framework refusing to
compile a leak. For someone who spends most of the working day on the side of
the system where the secrets live, that is the right default.
Forms that work without JavaScript
The rule editor is a form, and SvelteKit's answer to forms is the most backend-shaped thing I have seen in a front-end framework. The server file exports actions, which receive the form post:
// src/routes/rules/[id]/+page.server.ts
import { fail } from '@sveltejs/kit'
import type { Actions } from './$types'
import { saveRule } from '$lib/server/flat'
export const actions: Actions = {
default: async ({ request, params }) => {
const form = await request.formData()
const name = String(form.get('name') ?? '').trim()
if (!name) return fail(400, { name, missing: true })
await saveRule(params.id, { name, enabled: form.get('enabled') === 'on' })
return { saved: true }
},
}
That is a request handler. It takes a standard Request, reads standard
FormData, validates, returns a 400 with the submitted values on failure. The
form in the page is an ordinary <form method="POST">, and it works with
JavaScript switched off - the browser posts, the server responds, the page
re-renders. Adding use:enhance to the form upgrades it to a fetch without a
full reload, and nothing else about the code changes.
Compare that with the React version of the same form, which I have written many
times: controlled inputs, a submit handler, preventDefault, a loading state, an
error state and an API route to post to. Or the Angular version: a reactive form
group, validators, a service, HttpClient. Both are fine. SvelteKit simply
starts from the HTML form and the HTTP request, which is where I would start too.
There are also +server.ts files for plain endpoints - export a GET or a
POST function that returns a Response - and adapters that decide how the
whole thing is deployed: Node, Vercel, a static site, and others. I used the
Node adapter and ran the build on the same machine as the rest of the flat's
tooling.
For someone who avoids CSS
Styles are scoped by default, like Vue's <style scoped>, but you do not have to
ask for it. class:off={!device.on} toggles a class without a template string.
And Svelte ships transitions and animations in the box:
<script>
import { fade } from 'svelte/transition'
</script>
{#if saved}
<p transition:fade>Saved.</p>
{/if}
The one thing that separates my front ends from ones that look finished is that
nothing on mine moves. transition:fade on the save confirmation and
transition:slide on the rule details made the page look like someone had cared
about it. I wrote no CSS for either.
Reactivity, and what is coming
Svelte 4 reactivity is based on assignment. count = count + 1 updates the
page, because the compiler sees the assignment. devices.push(device) does not,
because nothing was assigned; you write devices = [...devices, device]
instead. The rule is simple once you know it, and until then it produces a bug
with no error.
$: has a similar edge. It tracks the variables that appear in the statement,
and only those. If the statement calls a function that reads something else, that
something else is not a dependency. It is a compile-time guess, and most of the
time it guesses right.
State shared between components goes in stores - writable, readable,
derived - and inside a component you read one with a $ prefix,
$rules, which subscribes and unsubscribes for you. It is less structured than
Pinia, and for this page I barely needed it, because SvelteKit's load did the
job a store did in the Vue version.
Svelte 5 is set to change all of this. It was announced in September with
runes: $state, $derived, $effect and $props, which replace let
state, $: and export let. I spent an evening with the preview. Runes are
signals, and in function they are close to Vue's ref and computed or
Angular's signal and computed - explicit, tracked at runtime, working in
plain .svelte.ts files as well as components. The difference is that they are
compiled, so there is no .value. I said in July that I got used to .value and
never came to like it. Runes are the same idea without it.
That is three frameworks converging on the same model. Vue has had it since 3.0,
Angular made signal and computed stable in 17 in November, and Svelte is
moving to it in 5. React is still the exception, and its answer, according to
last month's React Labs update, is a compiler - the React Compiler, already
running at Instagram, which memoises automatically so that you stop writing
dependency arrays by hand. Svelte has been saying for years that the framework
should be a compiler. It is interesting to see React agree, from the other
direction.
Testing, which was Vitest
In July Vitest was at 0.33 and I said to pin it. It reached 1.0 in December, and 1.3 is current. The advice is now simply to use it.
create-svelte puts the test configuration straight into vite.config.ts:
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [sveltekit()],
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
},
})
I added two of those lines. environment: 'jsdom' is needed for component
tests. The resolve line is less obvious: without it, Vitest picks Svelte's
server build rather than its browser one, and component tests fail in confusing
ways. I found it in a GitHub issue, not in the documentation.
After that, a component test with Testing Library reads like a React one:
import { render, screen } from '@testing-library/svelte'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import DeviceList from '$lib/DeviceList.svelte'
describe('DeviceList', () => {
it('filters devices by name', async () => {
const devices = [
{ id: 'kitchen', name: 'Kitchen light', room: 'kitchen', on: true },
{ id: 'hallway', name: 'Hallway light', room: 'hallway', on: false },
]
render(DeviceList, { props: { devices } })
await userEvent.type(screen.getByPlaceholderText('Filter devices'), 'kitchen')
expect(screen.getByText('Kitchen light')).toBeTruthy()
expect(screen.queryByText('Hallway light')).toBeNull()
})
})
The watch mode is the same as with Vue. The best testing news this week, though,
was that I could test the server half of the application as plain TypeScript.
A load function is an async function that returns an object. An action takes
a Request. I tested the rule action by building a Request with a FormData
body and checking the 400, which is exactly how I would test a handler in a
backend service.
What I liked
There is very little of it. Every component was shorter than its Vue equivalent, and the Vue one was shorter than React or Angular would have been, with less framework in the code.
In SvelteKit's server model, load, actions and +server.ts are the web's own
Request, Response and FormData with routing around them, so I did not
have to learn a framework's idea of HTTP.
The enforced server and client boundaries - the private environment variables
and $lib/server - are the feature I would point to if someone asked why a
backend developer should look at this.
Built-in transitions are a small thing that changed how finished the page felt, for someone who will never write a keyframe willingly.
Vite and Vitest were the same tooling as the Vue week. Moving between the two frameworks, I kept my config knowledge, my test runner and my habits.
What I did not like
Assignment-based reactivity cost me half an hour on the first Saturday, when
push silently did not update the page. The fix is trivial; finding it was not.
$: is too clever: a labelled statement whose dependencies are whatever the
compiler can see in it is elegant until it isn't. I ended up wrapping some logic
in functions and passing the dependencies in explicitly, which is the kind of
workaround that tells you the abstraction leaked.
There is no TypeScript in the markup. In Svelte 4, the <script> block is
TypeScript, but expressions in the markup are not - no type assertions or
generics inside {...}. Type checking also needs svelte-check, the same
separate-toolchain shape as vue-tsc. Svelte 5 fixes the first part.
Everything is called +page: SvelteKit's routing puts a +page.svelte and a
+page.server.ts in every folder. With five routes open I had ten editor tabs
with two names between them.
The good about the framework
It is fast, and not in the way benchmarks usually mean. The page starts fast because there is little JavaScript to download, the dev server is fast because it is Vite, and it is fast to write because there is less code. For a small team, or for one person on a weekend, that compounds.
SvelteKit's design decisions are good ones. Server first, progressive enhancement by default, web-standard APIs, adapters so the same application deploys as a Node server, a static site or serverless functions. It has the opinions I want from a framework - one way to route, one way to load data, one way to handle a form - and they are opinions I agree with.
It is also well supported. Rich Harris, who created Svelte, has worked on it full-time at Vercel for more than two years, and SvelteKit reached 1.0 in December 2022 and 2.0 last December.
The bad about the framework
The ecosystem is small - smaller than Vue's, and much smaller than React's. There are good component libraries - Skeleton, shadcn-svelte, Flowbite's Svelte version - but for most needs there is one choice, not five, and for some there is none and you wrap a plain JavaScript library yourself. Svelte makes that easy, but you still have to do it.
Svelte 5 is coming, and it changes the core. Runes replace let state, $:
and export let; event handlers become onclick instead of on:click; slots
become snippets. The team says Svelte 4 syntax will keep working, and the new
model is better. But anyone learning Svelte this spring is learning a syntax the
framework has already announced it is leaving behind, and most tutorials,
answers and libraries will be split between the two for a while. Vue's Options
and Composition APIs have the same problem, and I complained about that in July.
Fewer people know it. That does not matter for a personal project, but for a team, hiring, onboarding and finding answers are all harder than with React or Angular. Most answers online also still assume Svelte 3, or even Sapper, the predecessor to SvelteKit.
The compiler has its own semantics. let that triggers a re-render, a label
that means "reactive", assignment that matters and mutation that doesn't. It is
not quite JavaScript, and you have to learn where it isn't. Svelte 5 makes that
more explicit, which is an admission the old model was a little too magical.
Where I landed
I am impressed, more than I was with Vue, and I did not expect that.
As someone who is mostly focused on the backend, I like Svelte quite a lot, and I like SvelteKit more. It is the first front-end framework I have used that treats the server as the natural place for data and secrets, and the HTTP request as the basic unit of work. It feels less like I am learning a front-end framework and more like a framework written by someone who thinks about systems the way I do, and then made the browser half pleasant as well.
In July I said Vue would be my first choice for small tools. Svelte has taken that place. And it may be more than that: the next project I start from nothing will probably be SvelteKit. If Svelte 5 is stable by then, I will start with runes; if it is not, I will start with Svelte 4 and move with the upgrade.
Angular is still what I would choose for a large team that wants every question answered in advance, and React is still what I would use where the ecosystem matters most. But for my own work, where the person writing the front end is also the person who owns the API, the database and the deployment, Svelte is the one that fits.


