Tutorial 12 of 54 · for anyone who would rather write Vue than React · Markdown

Write your islands in Vue

You'll start a fresh app, point it at Vue and write a single-file component the server renders and the browser hydrates. Vue is the only framework in the import map, the vendor tree, the bundle and the type declarations. The pages are lowered templates as usual and the Vue component is lowered too.

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_vue
fsr --version

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

Install the Vue plugin#

snapfirec doesn't compile Vue itself. When it sees a .vue file it goes looking for snapfirec-vue on your PATH and hands it every .vue file in the project in one batch. The plugin carries Vue's own compiler and runs it in QuickJS. That's the third cargo install in the box above:

$ snapfirec-vue --version
snapfirec-vue 0.2.1 (@vue/compiler-sfc 3.5.44)

That @vue/compiler-sfc version belongs to the plugin. It compiles your components whatever version of Vue you end up vendoring for the browser.

Scaffold, then point it at Vue#

A plain fsr new writes no framework, which is what you want here.

$ fsr new box
wrote     box/.gitignore
wrote     box/config/app.toml
wrote     box/config/development.toml
wrote     box/config/production.toml
wrote     box/app/importmap.json
wrote     box/app/src/main.ts
wrote     box/app/routes/layout.tsx
wrote     box/app/routes/page.loader.ts
wrote     box/app/routes/page.tsx
wrote     box/app/routes/not-found.tsx
wrote     box/app/routes/error.tsx
wrote     box/app/styles/app.css
wrote     box/app/generated/plan.sexp
wrote     box/app/generated/contracts/schemas.json
wrote     box/app/.fsr/bundle/routes/layout.tsx
wrote     box/app/.fsr/bundle/routes/error.tsx
wrote     box/app/.fsr/bundle/routes/not-found.tsx
wrote     box/app/.fsr/bundle/routes/page.tsx
wrote     box/app/generated/mdx.d.ts
wrote     box/app/generated/shell.json
wrote     box/app/generated/uploads.d.ts
wrote     box/app/generated/assets.d.ts
wrote     box/app/generated/assets.json
wrote     box/app/generated/assets.map.json
wrote     box/app/generated/native.d.ts
wrote     box/app/generated/services.d.ts
wrote     box/app/generated/elements.d.ts
wrote     box/app/generated/head.ts
wrote     box/app/generated/config.ts
wrote     box/app/generated/config.schema.json
wrote     box/app/generated/host.ts
wrote     box/app/generated/islands.ts
wrote     box/app/generated/client.ts
wrote     box/app/generated/testing.ts
wrote     box/app/tsconfig.json
wrote     box/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/box/config/app.toml
next      fsr use box/app react      # only if the application wants React; also vue, elements, htmx, tailwind, tera or mdx
next      fsr dev box/app

Run the fsr use line it suggests, with vue instead of react:

$ fsr use box/app vue
added     vue                          vue/vue.bundle.mjs  128682 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     vue                          vue 3.5.44
types     @vue/compiler-dom            @vue/compiler-dom 3.5.44
types     @vue/runtime-dom             @vue/runtime-dom 3.5.44
types     @vue/compiler-sfc            @vue/compiler-sfc 3.5.44
types     @vue/server-renderer         @vue/server-renderer 3.5.44
types     @vue/compiler-core           @vue/compiler-core 3.5.44
types     @vue/runtime-core            @vue/runtime-core 3.5.44
types     csstype                      csstype 3.2.3
types     @babel/parser                @babel/parser 7.29.11
types     magic-string                 magic-string 0.30.21
types     postcss                      postcss 8.5.31
types     @vue/shared                  @vue/shared 3.5.44
types     @vue/reactivity              @vue/reactivity 3.5.44
types     @babel/types                 @babel/types 7.29.11
types     source-map-js                source-map-js 1.2.2
wrote     box/app/generated/plan.sexp
wrote     box/app/generated/contracts/schemas.json
wrote     box/app/.fsr/bundle/routes/layout.tsx
wrote     box/app/.fsr/bundle/routes/error.tsx
wrote     box/app/.fsr/bundle/routes/not-found.tsx
wrote     box/app/.fsr/bundle/routes/page.tsx
wrote     box/app/generated/mdx.d.ts
wrote     box/app/generated/shell.json
wrote     box/app/generated/uploads.d.ts
wrote     box/app/generated/assets.d.ts
wrote     box/app/generated/assets.json
wrote     box/app/generated/assets.map.json
wrote     box/app/generated/native.d.ts
wrote     box/app/generated/services.d.ts
wrote     box/app/generated/elements.d.ts
wrote     box/app/generated/head.ts
wrote     box/app/generated/config.ts
wrote     box/app/generated/config.schema.json
wrote     box/app/generated/host.ts
wrote     box/app/generated/islands.ts
wrote     box/app/generated/client.ts
wrote     box/app/generated/testing.ts
wrote     box/app/tsconfig.json
wrote     box/app/tsconfig.build.json

That one command vendors Vue as a plain file and fetches the declarations. The import map names Vue and nothing else:

app/importmap.json
{
  "imports": {
    "vue": "/static/js/vendor/vue/vue.bundle.mjs"
  }
}

The host adds the FSR entries when it serves the page, one for each client module the bundle imports, so the file only names what fsr use vendored. There's no React anywhere in it. fsr use box/app vue --example would write a starter component too, which this tutorial writes by hand instead.

The *.vue module declaration your editor wants is not there yet. It's written once there's a .vue import in the project to write it for, so run fsr types box/app again after the next section rather than now.

Write the layout#

The scaffolded layout already takes Link from @snapfire/fsr/components and types its children as Children from @snapfire/fsr/template, because a bare application has no React to type them against. That's the dialect this whole tutorial stays in:

app/routes/layout.tsx
import { Island, type Children } from "@snapfire/fsr/template";
import { Link } from "@snapfire/fsr/components";

import Tonight from "@src/ui/Tonight.vue";

export default function BoxLayout({ children, planned }: { children: Children; planned: number }) {
  return (
    <div className="shell">
      <header className="bar">
        <Link href="/" className="brand">
          box
        </Link>
        <Island when="load">
          <Tonight count={planned} />
        </Island>
      </header>
      <main className="content">{children}</main>
    </div>
  );
}

Children is what you write here where a React layout writes ReactNode. Nothing else about the file changes.

The build reads import Tonight from "@src/ui/Tonight.vue" and asks snapfirec-vue to describe the file: the template as Vue's own parser reads it, the <script setup> block and how the template reads each name the script binds. It lowers the component from that, to the same render tree a TSX template lowers to, so the server writes the component's markup. It still has to go inside Island: Vue's root is the island's, so a component Vue mounts can only be an island. Put it anywhere else and the build fails, naming the tag.

planned is a prop like any other, so something has to load it. The layout's loader does that and seeds the store key in the same file:

app/routes/layout.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx) {
  return { planned: 2 };
}

export const store = ({ data }: { data: { planned: number } }) => ({ "box/planned": data.planned });

Write the component#

app/src/ui/Tonight.vue is an ordinary single-file component:

app/src/ui/Tonight.vue
<script setup lang="ts">
import { ref } from "vue";
import { useStore } from "@snapfire/fsr/client/vue";

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

const props = defineProps<{ count: number }>();
const held = useStore(plannedCount, props.count);
const open = ref(false);
</script>

<template>
  <div class="tonight">
    <button class="tonight-count" @click="open = !open">{{ held.value }} for tonight</button>
    <p v-if="open" class="tonight-note">Kept in the session. This is Vue state in the root layout, so it stays open as you move between recipes.</p>
  </div>
</template>

<style scoped>
.tonight-count {
  border: 1px solid #1b1d21;
  border-radius: 999px;
  background: #fff;
  padding: 6px 14px;
  font: inherit;
  cursor: pointer;
}
</style>

The two reactive values in the template behave differently. open is a real ref, so Vue unwraps it and you write open. held is not: useStore gives you back a reactive object with a .value on it, so you write held.value. Write {{ held }} and you'll render the object instead of the count.

@snapfire/fsr/client/vue is the same keyed store a React island reads through its own hook. The key is declared once in plain TypeScript; the adapter is the only part that differs by framework:

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

export const plannedCount = key<number>("box/planned");

The count arrives twice, as a prop and as a store key. The prop is the server's value for this render: it is in the markup, it is in the props script beside the marker and the component has it before any store exists, so the first paint is right. The store key makes it shared and reactive afterwards. useStore(plannedCount, props.count) reads the key if something already seeded it and falls back to the prop if not, then follows the key from then on. That is how a second island on the page, in any framework, can move this number without either component knowing about the other.

Without the prop, the button shows the store's default until the seed is read. Without the key, the count never changes from what this render said.

Fetch the declarations#

Now that there's a .vue file to declare, run it:

$ fsr types box/app
types     @snapfire/fsr/authoring      fsr 0.21.0
types     @snapfire/fsr/client         fsr 0.21.0
types     @snapfire/fsr/desktop        fsr 0.21.0
kept      vue
kept      @vue/compiler-dom
kept      @vue/runtime-dom
kept      @vue/compiler-sfc
kept      @vue/server-renderer
kept      @vue/compiler-core
kept      @vue/runtime-core
kept      csstype
kept      @babel/parser
kept      magic-string
kept      postcss
kept      @vue/shared
kept      @vue/reactivity
kept      @babel/types
kept      source-map-js
wrote     types/foreign.d.ts
wrote     tsconfig.json

vue and the packages its declarations import say kept because fsr use already fetched them. Declarations already sitting in app/types/ are left alone unless you pass --refresh; the two fsr packages come out of the binary and are rewritten every time. types/foreign.d.ts is the new part: the *.vue module declaration that needed a .vue file to exist first.

types/foreign.d.ts is the shim that makes import Tonight from "@src/ui/Tonight.vue" typecheck. It's a loose declaration that takes any props:

app/types/foreign.d.ts
// Generated by fsr. Do not edit.

declare module "*.vue" {
  const component: (props: { [prop: string]: unknown }) => any;
  export default component;
}

Tonight.vue declares its props with defineProps<{ count: number }>(), so the build also writes a declaration of its own. The editor finds that one first:

app/generated/vue/src/ui/Tonight.d.vue.ts
declare const component: (props: ({ count: number }) & { children?: unknown }) => any;
export default component;

Pass the wrong type at the placement, <Tonight count="two">. The build fails at that line:

$ fsr build box/app
typecheck: tsc 7.0.2 from cache, 1 error
routes/layout.tsx(14,20): error TS2322: Type 'string' is not assignable to type 'number'.

Build it#

$ fsr build box/app --verbose
   Plugin cache: 0 of 1 answered
   Plugin:   vue 0.2.1 (@vue/compiler-sfc 3.5.44), through the driver
   Compiling TS: "src/main.ts"
   Compiling TS: "src/store.ts"
   Compiling VUE: "src/ui/Tonight.vue"
   Compiling CSS: "src/ui/Tonight.vue"
   ...
   Externals: '@snapfire/fsr/client', '@snapfire/fsr/client/store', '@snapfire/fsr/client/vue', 'vue'
routes    /                      routes
layouts   /                      routes/layout.tsx#default
sources   layout                 lowered     routes/layout.loader.ts
          $root                  lowered     routes/page.loader.ts
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/Tonight.vue#default         lowered     vue
...
plan      generated/plan.sexp    3.1 KB written
...
wrote /.../box/app/types/foreign.d.ts
wrote /.../box/app/generated/vue/src/ui/Tonight.d.vue.ts
typecheck tsc 7.0.2 from cache, clean

The component compiled to two files. dist/src/ui/Tonight.vue is the module, JavaScript under the source's name. dist/src/ui/Tonight.vue.css is the stylesheet, which holds the <style scoped> block with its data-v- attribute. The browser loads the module from /static/js/app/src/ui/Tonight.vue.

Read the last rendered row: the .vue file is lowered, with vue where a TSX template says static. The build read the component through snapfirec-vue and lowered it into the plan, so the host renders it. foreign in that column would mean it couldn't; a later section covers that case.

Every route module says static. A template with no state and no handlers of its own has nothing for the browser to change, so the build doesn't compile, register or bundle it. That's why the app loads no framework for its pages.

Add a useState to routes/page.tsx to see what happens otherwise. The page stops being static. The build cuts the part with state out of the page as routes/page.island0.tsx and the registry wants to mount it. A .tsx module mounts through React, which this app doesn't have:

$ fsr build box/app
`routes/page.island0.tsx#default` mounts through `@snapfire/fsr/client/react`, but the import map does not name `react` or `react-dom/client`; `fsr use <app dir> react` writes it

The build checks every adapter its registry would import against the import map before it writes anything, so you get the module, the adapter and the missing specifiers rather than a bare specifier error from the bundler. It also names the command that would fix it. You don't want React here, so take the useState back out instead.

Here's the registry the build wrote:

app/generated/islands.ts
import { registerDeferredIsland } from "@snapfire/fsr/client";

const vue = () => import("@snapfire/fsr/client/vue").then((m) => ({ mount: m.vueMounter, patch: m.vuePatcher, unmount: m.vueUnmounter }));

export function registerIslands(): void {
  registerDeferredIsland("src/ui/Tonight.vue#default", { loader: () => import("../src/ui/Tonight.vue").then((m) => m.default), adapter: vue });
}

patch runs when the island is already mounted and the server sends new props for it: after an action, after a revalidation or after a navigation that keeps the region. vueMounter mounts the component inside a one-element wrapper that holds its props reactively; vuePatcher assigns the new props onto that object, so Vue re-renders in place instead of tearing the island down and losing whatever state it had.

unmount handles removal. When a navigation takes away the markup an island was mounted in, the client hands the island to its entry's unmounter, nested islands first, so Vue runs the component's cleanup and the app is torn down. A root left running in a detached element never cleans up its effects, so it keeps whatever they hold.

adapter imports @snapfire/fsr/client/vue, which imports vue itself. The browser calls it the first time a page holds a Vue island, so a page with none loads no Vue. The loader defers the component module, Tonight.vue under dist/, the same way.

This application has no React island anywhere, so the build writes no React adapter and React is not reachable from the entry module at all. An island's adapter comes from its file extension: .vue goes to Vue's, anything the lowerer reads goes to React's. An extension no framework claims is a build error.

Read the server's output#

$ fsr serve box/app
$ curl -s localhost:3000/
<link rel="stylesheet" href="/static/js/app/styles/app.css">
...
<link rel="stylesheet" data-sf-css="" href="/static/js/app/src/ui/Tonight.vue.css">
...
<header class="bar"><a href="/" class="brand" data-sf-link="exact" aria-current="page">box</a><sf-s data-sf-island data-sf-region="routes/layout.tsx#default|i0" data-sf-when="load"><sf-i id="sf-i0" data-sf-module="src/ui/Tonight.vue#default" data-sf-ssr><div class="tonight" data-v-8c917f69><button class="tonight-count" data-v-8c917f69>2 for tonight</button><!----></div></sf-i><script type="application/json" data-sf-props="sf-i0">{"count":{"$":"f","v":2.0},"$sv":{"box/planned":{"$":"f","v":2.0}},"$k":"routes/layout.tsx#default|i0"}</script></sf-s></header>
...
<script type="application/json" data-sf-store>[{"k":"routes/layout.tsx#default","p":["content"],"v":{"box/planned":{"$":"f","v":2.0}}}]</script>

The component is in the markup. The server wrote what Vue's own server renderer would write for the same component and props, so Vue hydrates over it rather than mounting fresh, exactly as a React island does. Some of the markup is Vue's own spelling:

  • data-v-8c917f69 is the scoped style's stamp, on every element the component rendered. It matches the data-v- attribute in Tonight.vue.css.
  • <!----> is the anchor where the v-if rendered nothing. Vue leaves a comment node so it knows where to put the paragraph when open flips.
  • 2 for tonight is the count, in the markup before any script runs. useStore read the key the layout's loader seeded, on the server.

data-sf-ssr on the marker says the server rendered the component, so the browser hydrates it. $sv in the props is the store values the server's render read, so hydration starts from the same count.

The last line is the store seed, written once at the end of the document from the loader's store export and tagged with the layout that seeded it. That is where box/planned comes from: the client reads the seed during boot() and useStore finds the key already set.

data-sf-region is the placement's key. It's how the navigator works out that the island in the new markup is the same one that's already mounted, so it can hand it fresh props instead of remounting it. That's the key patch uses.

The component's stylesheet is linked after the app's own, so its rules land later in the cascade. The compiler lists the file under styles in dist/.snapfire-build.json and the host reads that at boot.

Pass children#

Markup the layout writes inside a Vue island reaches the component as its default slot. Put a <slot /> where it belongs:

app/src/ui/Tonight.vue
<template>
  <div class="tonight">
    <button class="tonight-count" @click="open = !open">{{ held.value }} for tonight</button>
    <p v-if="open" class="tonight-note"><slot /></p>
  </div>
</template>

and write the children in the layout:

app/routes/layout.tsx
<Island when="load">
  <Tonight count={planned}>
    Kept in the session. <a href="/tonight">See them</a>.
  </Tonight>
</Island>

Build and fetch the page again:

<sf-i id="sf-i0" data-sf-module="src/ui/Tonight.vue#default" data-sf-ssr><div class="tonight" data-v-1a03db4f><button class="tonight-count" data-v-1a03db4f>2 for tonight</button><!----></div><template data-sf-children>Kept in the session. <a href="/tonight">See them</a>.</template></sf-i>

<template data-sf-children> after the markup is the island's children. The panel is closed, so this render placed no <slot /> and there was nowhere to write them. An inert <template> carries them instead, which the parser never shows. The mounter reads it before Vue hydrates and takes it out of the document, so the slot has its content the moment the panel opens.

The children are the layout's markup, so the server renders them wherever this render put the <slot />. Open the panel on the server and they sit inside it, in an <sf-s data-sf-children> region Vue hydrates as an element it rendered. With it closed, as above, there is no slot to write them into; they ride along in the inert <template data-sf-children> and the mounter puts them in place before Vue hydrates.

Either way the browser doesn't render them again; it keeps what the server wrote, including inside a v-if that opens later. A React island gets the same region as its children.

Components the build can't read#

Not every component lowers. Add a <Teleport> to a class selector under the paragraph in Tonight.vue:

app/src/ui/Tonight.vue
<template>
  <div class="tonight">
    <button class="tonight-count" @click="open = !open">{{ held.value }} for tonight</button>
    <p v-if="open" class="tonight-note"><slot /></p>
    <Teleport to=".notes"><p>{{ held.value }} planned</p></Teleport>
  </div>
</template>

Build again:

$ fsr build box/app
rendered  src/ui/Tonight.vue#default         foreign     src/ui/Tonight.vue:16:5
foreign   src/ui/Tonight.vue:16:5            `<Teleport>` to a target that is neither `body` nor `#` and an id; the server places content only at the end of the body or at a `<Place>`
          1 component mounts in the browser for it, written empty by the server
            src/ui/Tonight.vue#default

The row says foreign instead of lowered and gives the file, line and column. The build carries on and the page still works: the server writes the marker empty with its props and Vue mounts the component fresh. Only that component loses its server-rendered first paint.

<sf-i id="sf-i0" data-sf-module="src/ui/Tonight.vue#default"><sf-s data-sf-children>Kept in the session. <a href="/tonight">See them</a>.</sf-s></sf-i>

The children are still there, because they're the layout's markup rather than the component's.

What lowers is the subset a server can evaluate. In <script setup>: defineProps with withDefaults around it, ref, shallowRef, computed of an arrow, reactive, useStore, a const bound to an expression the build reads and functions, which are the browser's. Lifecycle and watch calls are passed over, since the server never runs one. In the template: interpolation, v-if / v-else-if / v-else, v-for with an item and an index, a bound attribute, :class as a string, array or object, :style, v-show, v-html, v-text and a plain <slot />. v-model, v-bind of a whole object, named and scoped slots between .vue files, provide with inject and the Options API lower too. <Teleport to="body"> writes its content at the end of the body.

Residue is a component placed in the template that is not a .vue file the script imports, v-slot, a <Teleport> to a target that is neither body nor # and an id, a <Teleport defer> and a created that does anything but call this.$emit. Take the <Teleport> back out and the component goes back to lowered.

An integer a contract types as bigint arrives as an integer, so a component that multiplies it by a literal fails the render on the server where the browser never noticed. Convert it at the placement, serves={Number(recipe.serves)}, the same way a loader has to.

Use it with fsr dev#

fsr dev watches .vue files like anything else. With --verbose it prints what each rebuild did. Save Tonight.vue and you get:

$ fsr dev box/app --verbose
...
dev: changed app/src/ui/Tonight.vue
...
   Compiling VUE: "src/ui/Tonight.vue"
   Compiling CSS: "src/ui/Tonight.vue"
typecheck tsc 7.0.2 from cache, clean

and the browser picks up the new module. It's a refresh rather than Vue's own HMR, so the component starts from its props again.

When the plugin is missing#

Take snapfirec-vue off your PATH and build:

$ fsr build box/app
❌ `snapfirec-vue` is not on PATH; `cargo install snapfire_vue` puts it there
   needed by "src/ui/Tonight.vue"
plugins   `snapfirec-vue` is not on PATH, so no `.vue` component was read or compiled; `cargo install snapfire_vue` puts it there, and until it does the bundle stops on the module the registry imports
snapfirec failed:
...

You get the binary it went looking for, the command that installs it and every file that needed it. The plugins row says what follows from it: with nothing to compile the .vue file, the module the registry imports is never written, so the bundle stops. The build exits with an error here and serves nothing.

Host configuration#

An island placement carries a module id. The registry maps that id to an adapter. Nothing in the plan, the payload or the renderer reads that id, which is why putting Vue where React was took no host configuration at all.

Next up: 205. Write your islands in Svelte.