---
title: "Write your islands in Svelte"
audience: "anyone who would rather write Svelte than React"
description: "You'll put a Svelte counter and a React counter on one page and make them share one number. Click either one and both move…"
snippet_caption: "app/src/ui/SvelteCounter.svelte"
snippet: "<script lang=\"ts\">\n  import { useStore } from \"@snapfire/fsr/client/svelte\";\n\n  import { clicks } from \"@src/store\";\n\n  let { start }: { start: number } = $props();\n\n  const n = useStore(clicks, start);\n</script>\n\n<div class=\"counter\">\n  <span class=\"label\">Svelte</span>\n  <button aria-label=\"Decrease\" onclick={() => ($n -= 1)}>-</button>\n  <output>{$n}</output>\n  <button aria-label=\"Increase\" onclick={() => ($n += 1)}>+</button>\n</div>"
---

import { Install } from "@src/ui/Install";

You'll put a Svelte counter and a React counter on one page and make them share one number. Click either one and both move. The server renders both counters with the starting value, so the number is in the markup before any script runs.

<Install captured="0.x" also={["snapfire_svelte"]} />

## Check the Svelte plugin

`snapfirec` doesn't compile Svelte itself. When it sees a `.svelte` file it hands it to `snapfirec-svelte`, which it looks for on your `PATH`. That's the third `cargo install` in the box above:

```shell-session
$ snapfirec-svelte --version
snapfirec-svelte 0.1.0 (svelte 5.57.2)
```

## Scaffold the app

A plain `fsr new` writes no framework:

```shell-session
$ fsr new counter
wrote     counter/.gitignore
wrote     counter/config/app.toml
wrote     counter/config/development.toml
wrote     counter/config/production.toml
wrote     counter/app/importmap.json
wrote     counter/app/src/main.ts
wrote     counter/app/routes/layout.tsx
wrote     counter/app/routes/page.loader.ts
wrote     counter/app/routes/page.tsx
wrote     counter/app/routes/not-found.tsx
wrote     counter/app/routes/error.tsx
wrote     counter/app/styles/app.css
wrote     counter/app/generated/plan.sexp
wrote     counter/app/generated/contracts/schemas.json
wrote     counter/app/.fsr/bundle/routes/layout.tsx
wrote     counter/app/.fsr/bundle/routes/error.tsx
wrote     counter/app/.fsr/bundle/routes/not-found.tsx
wrote     counter/app/.fsr/bundle/routes/page.tsx
wrote     counter/app/generated/mdx.d.ts
wrote     counter/app/generated/shell.json
wrote     counter/app/generated/uploads.d.ts
wrote     counter/app/generated/assets.d.ts
wrote     counter/app/generated/assets.json
wrote     counter/app/generated/assets.map.json
wrote     counter/app/generated/native.d.ts
wrote     counter/app/generated/services.d.ts
wrote     counter/app/generated/elements.d.ts
wrote     counter/app/generated/head.ts
wrote     counter/app/generated/config.ts
wrote     counter/app/generated/config.schema.json
wrote     counter/app/generated/host.ts
wrote     counter/app/generated/islands.ts
wrote     counter/app/generated/client.ts
wrote     counter/app/generated/testing.ts
wrote     counter/app/tsconfig.json
wrote     counter/app/tsconfig.build.json
types     @snapfire/fsr/authoring      fsr 0.21.0
types     @snapfire/fsr/client         fsr 0.21.0
types     @snapfire/fsr/desktop        fsr 0.21.0
recorded  typecheck.version = "7.0.2" in /path/to/counter/config/app.toml
next      fsr use counter/app react      # only if the application wants React; also vue, elements, htmx, tailwind, tera or mdx
next      fsr dev counter/app
```

## Add Svelte

Name the Svelte version when you add it:

```shell-session
$ fsr use counter/app svelte@5.57.2
added     clsx                         clsx/dist/clsx.mjs  388 bytes
added     esm-env                      esm-env/index.js  156 bytes
added     esm-env/browser              esm-env/true.js  21 bytes
added     esm-env/development          esm-env/false.js  22 bytes
added     esm-env/node                 esm-env/false.js  22 bytes
added     svelte                       svelte/src/index-client.js  8312 bytes
added     svelte/animate               svelte/src/animate/index.js  2381 bytes
added     svelte/attachments           svelte/src/attachments/index.js  3870 bytes
added     svelte/easing                svelte/src/easing/index.js  6435 bytes
added     svelte/events                svelte/src/events/index.js  64 bytes
added     svelte/internal/client       svelte/src/internal/client/index.js  5747 bytes
added     svelte/internal/disclose-version svelte/src/internal/disclose-version.js  174 bytes
added     svelte/internal/flags/legacy svelte/src/internal/flags/legacy.js  82 bytes
added     svelte/legacy                svelte/src/legacy/legacy-client.js  8445 bytes
added     svelte/motion                svelte/src/motion/index.js  895 bytes
added     svelte/reactivity            svelte/src/reactivity/index-client.js  324 bytes
added     svelte/store                 svelte/src/store/index-client.js  3402 bytes
added     svelte/transition            svelte/src/transition/index.js  10486 bytes
types     @snapfire/fsr/authoring      fsr 0.21.0
types     @snapfire/fsr/client         fsr 0.21.0
types     @snapfire/fsr/desktop        fsr 0.21.0
types     clsx                         clsx 2.1.1
types     esm-env                      esm-env 1.2.2
types     svelte                       svelte 5.57.2
types     esrap                        esrap 2.4.0
types     locate-character             locate-character 3.0.0
types     magic-string                 magic-string 0.30.21
wrote     counter/app/generated/plan.sexp
...
```

Svelte is vendored as it is published on npm, one file per module, with every subpath in the import map. Its subpaths share one runtime through relative imports, so they have to stay together in one tree under `app/vendor/svelte/`. `clsx` and `esm-env` are the two packages Svelte imports.

The build compiles your components with the compiler of the version you named. It fetches that compiler into `app/.fsr/compilers/` the first time and never ships it. To move to another release later, run `fsr use counter/app svelte@<version>` again.

## Add React

The second counter is React, so add that direction too:

```shell-session
$ fsr use counter/app react
added     react                        react/react.bundle.mjs  12280 bytes
added     react/jsx-runtime            react/jsx-runtime.bundle.mjs  1582 bytes
added     react-dom/client             react-dom/client.bundle.mjs  215268 bytes
types     @snapfire/fsr/authoring      fsr 0.21.0
types     @snapfire/fsr/client         fsr 0.21.0
types     @snapfire/fsr/desktop        fsr 0.21.0
types     react                        @types/react 19.3.0
types     react-dom                    @types/react-dom 19.3.0
types     csstype                      csstype 3.2.3
```

The `wrote` lines after it are left out here. The import map now names Svelte and React. The host adds both adapters, `@snapfire/fsr/client/svelte` and `@snapfire/fsr/client/react`, when it serves the page.

Give the app a port of its own in `config/app.toml`:

```toml title="config/app.toml"
[server]
listen = "127.0.0.1:3401"
```

## Declare the store key

Both counters read one key. Declare it once in plain TypeScript:

```ts title="app/src/store.ts"
import { key } from "@snapfire/fsr/client/store";

export const clicks = key<number>("counter/clicks");
```

## Write the Svelte counter

```svelte title="app/src/ui/SvelteCounter.svelte"
<script lang="ts">
  import { useStore } from "@snapfire/fsr/client/svelte";

  import { clicks } from "@src/store";

  let { start }: { start: number } = $props();

  const n = useStore(clicks, start);
</script>

<div class="counter">
  <span class="label">Svelte</span>
  <button aria-label="Decrease" onclick={() => ($n -= 1)}>-</button>
  <output>{$n}</output>
  <button aria-label="Increase" onclick={() => ($n += 1)}>+</button>
</div>
```

`useStore` from `@snapfire/fsr/client/svelte` gives you a Svelte store over the FSR key. You read it with `$n` and you write it with `$n += 1`, the way you use any Svelte store. Every other island that reads `counter/clicks` follows the write, whatever framework it's in.

`start` is the fallback. `useStore(clicks, start)` reads the key when something has set it and uses `start` when nothing has.

## Write the React counter

```tsx title="app/src/ui/ReactCounter.tsx"
import { useStore } from "@snapfire/fsr/client/react";

import { clicks } from "@src/store";

export function ReactCounter({ start }: { start: number }) {
  const [n, setN] = useStore(clicks, start);
  return (
    <div className="counter">
      <span className="label">React</span>
      <button aria-label="Decrease" onClick={() => setN(n - 1)}>
        -
      </button>
      <output>{n}</output>
      <button aria-label="Increase" onClick={() => setN(n + 1)}>
        +
      </button>
    </div>
  );
}
```

React's `useStore` returns a value and a setter. It is the same key in the same store.

## Seed the key and place both counters

The page's loader returns the starting number and seeds the key with it:

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx<"/">) {
  return { start: 3 };
}

export const store = ({ data }: { data: { start: number } }) => ({ "counter/clicks": data.start });
```

The page places each counter inside an `Island`:

```tsx title="app/routes/page.tsx"
import { Island } from "@snapfire/fsr/template";
import type { RootProps } from "@generated/client";

import SvelteCounter from "@src/ui/SvelteCounter.svelte";
import { ReactCounter } from "@src/ui/ReactCounter";

export default function Index({ start }: RootProps) {
  return (
    <section className="hero">
      <h1>Two counters, one number</h1>
      <Island when="load">
        <SvelteCounter start={start} />
      </Island>
      <Island when="load">
        <ReactCounter start={start} />
      </Island>
    </section>
  );
}
```

A `.svelte` file is imported by its default export. A component Svelte mounts can only be an island, so it has to sit inside `Island`.

Add a little layout to `app/styles/app.css`:

```css title="app/styles/app.css"
.counter {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 8px 0;
}
```

## Build it

```shell-session
$ fsr build counter/app --verbose
...
   Plugin:   svelte 0.1.0 (svelte 5.57.2), through the driver
   Compiling TS: "src/main.ts"
   Compiling TS: "src/store.ts"
   Compiling TS: "src/ui/ReactCounter.tsx"
   Compiling SVELTE: "src/ui/SvelteCounter.svelte"
...
rendered  routes/error.tsx#default           lowered     static
          routes/layout.tsx#default          lowered     static
          routes/not-found.tsx#default       lowered     static
          routes/page.tsx#default            lowered     static
          src/ui/ReactCounter.tsx#ReactCounter lowered     
          src/ui/SvelteCounter.svelte#default lowered     svelte
...
typecheck tsc 7.0.2 from cache, clean
```

Read the two island rows. Both say `lowered`, so the server renders both. The Svelte row says `svelte` in the last column: the build read the component through `snapfirec-svelte` and lowered it into the plan. The page itself is `static`, since it has no state of its own.

The build registers each island with its own framework's mounter:

```ts title="app/generated/islands.ts"
// Generated by fsr build. Do not edit.

import { registerIsland } from "@snapfire/fsr/client";
import { svelteMounter, sveltePatcher, svelteUnmounter, registerTree } from "@snapfire/fsr/client/svelte";
import { reactMounter, reactPatcher, reactUnmounter, reactPauser } from "@snapfire/fsr/client/react";
// @ts-ignore: the snippet Pieces.svelte exports from its module script, which the declaration of a `.svelte` file leaves out
import { tree } from "./components/Pieces.svelte";

export function registerIslands(): void {
  registerTree(tree);
  registerIsland("src/ui/SvelteCounter.svelte#default", { loader: () => import("../src/ui/SvelteCounter.svelte").then((m) => m.default), mount: svelteMounter, patch: sveltePatcher, unmount: svelteUnmounter });
  registerIsland("src/ui/ReactCounter.tsx#ReactCounter", { loader: () => import("../src/ui/ReactCounter.js").then((m) => m.ReactCounter), mount: reactMounter, patch: reactPatcher, unmount: reactUnmounter, pause: reactPauser });
}
```

## Read the server's output

```shell-session
$ fsr serve counter/app
fsr server on http://127.0.0.1:3401/
fsr traces on http://127.0.0.1:3401/__fsr/traces
```

```shell-session
$ curl -s localhost:3401/
...
<sf-i id="sf-i0" data-sf-module="src/ui/SvelteCounter.svelte#default" data-sf-ssr><!--[--><div class="counter"><span class="label">Svelte</span> <button aria-label="Decrease">-</button> <output>3</output> <button aria-label="Increase">+</button></div><!--]--></sf-i>
...
<sf-i id="sf-i1" data-sf-module="src/ui/ReactCounter.tsx#ReactCounter" data-sf-ssr><div class="counter"><span class="label">React</span><button aria-label="Decrease">-</button><output>3</output><button aria-label="Increase">+</button></div></sf-i>
...
<script type="application/json" data-sf-store>[{"k":"routes/page.tsx#default","p":["content","content"],"v":{"counter/clicks":{"$":"f","v":3.0}}}]</script>
```

Both counters show `3` before any script runs. The Svelte markup carries `<!--[-->` and `<!--]-->`, the hydration markers Svelte's own server renderer writes, so Svelte hydrates over it in the browser. The last line is the store seed from the loader's `store` export.

## Click either counter

Open `http://127.0.0.1:3401/` in a browser. Click the Svelte `+` twice and both counters read `5`. Click the React `-` once and both read `4`. Neither component imports the other. They meet only at the `counter/clicks` key.

Next up: [210. Write islands as web components](https://snapfirers.com/fsr/learn/210-write-islands-as-web-components).
