svelte

One week with 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/private or $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.

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

© 2026 Deyan Peev