Tutorial 13 of 54 · for anyone who would rather write Svelte than React · Markdown

Write your islands in Svelte

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
cargo install snapfire_svelte
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

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:

$ snapfirec-svelte --version
snapfirec-svelte 0.1.0 (svelte 5.57.2)

Scaffold the app#

A plain fsr new writes no framework:

$ 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:

$ 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:

$ 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:

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:

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

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

Write the Svelte counter#

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#

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:

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:

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:

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

Build it#

$ 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:

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#

$ fsr serve counter/app
fsr server on http://127.0.0.1:3401/
fsr traces on http://127.0.0.1:3401/__fsr/traces
$ 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.