preveltekit Svelte Themes

Preveltekit

PrevelteKit is a lightweight, high-performance web application framework written in Go, featuring server-side rendering with WebAssembly hydration.

PrevelteKit 2.0

Build reactive web apps in Go. Components compile to WebAssembly, with server-side pre-rendering for instant page loads.

Features

  • Reactive stores - Store[T], List[T] with automatic DOM updates
  • Typed Go DSL - build UI trees with Div(), P(), Button(), etc. — no template language or code generation
  • Two-way binding - .Bind() for text, number, and checkbox inputs
  • Event handling - .On("click", fn), .PreventDefault(), .StopPropagation()
  • Scoped CSS - per-component styles with automatic class scoping
  • Client-side routing - SPA navigation with path parameters
  • Typed fetch - generic HTTP client with automatic JSON encoding/decoding
  • LocalStorage - persistent stores that sync automatically
  • SSR + Hydration - pre-rendered HTML at build time, hydrated with WASM at runtime

Quick Start

mkdir hello && cd hello
go mod init hello
go run github.com/tbocek/preveltekit/v2/cmd/build@latest init
./dev.sh

Open http://localhost:8080. Edit main.go and the browser reloads automatically.

For a production build: ./build.sh --release outputs static files to dist/.

Docker Quick Start

Development (live reload):

mkdir hello && cd hello
go mod init hello
go run github.com/tbocek/preveltekit/v2/cmd/build@latest init
docker build -f Dockerfile.dev -t hello-dev .
docker run --rm -it --init -p 8080:8080 -v $PWD:/app hello-dev

Production (multi-stage build with Caddy):

mkdir hello && cd hello
go mod init hello
go run github.com/tbocek/preveltekit/v2/cmd/build@latest init
docker build -t hello .
docker run -p 8080:8080 hello
import p "github.com/tbocek/preveltekit/v2"

Hello World

type Hello struct{}

func (h *Hello) Render() p.Node {
    return p.H1("Hello, World!")
}

Reactive Counter

Stores hold reactive state. Embed them in typed element functions and they update the DOM automatically.

type Counter struct {
    Count *p.Store[int]
}

func (c *Counter) New() p.Component {
    return &Counter{Count: p.New(0)}
}

func (c *Counter) Render() p.Node {
    return p.Div(
        p.P("Count: ", p.Strong(c.Count)),
        p.Button("+1").On("click", func() {
            c.Count.Update(func(v int) int { return v + 1 })
        }),
    )
}

p.New(0) creates a *Store[int] with initial value 0. Pass it as a child to any typed element and it becomes a live text node. .On("click", fn) wires up an event handler.

Two-Way Binding

Bind a store to an input. Changes flow both ways.

type Greeter struct {
    Name *p.Store[string]
}

func (g *Greeter) New() p.Component {
    return &Greeter{Name: p.New("")}
}

func (g *Greeter) Render() p.Node {
    return p.Div(
        p.Label("Name: ", p.Input(p.Attr("type", "text")).Bind(g.Name)),
        p.P("Hello, ", g.Name, "!"),
    )
}

.Bind() works with *Store[string], *Store[int], and *Store[bool] (checkbox).

Conditionals

score := p.New(75)

p.If(p.Cond(func() bool { return score.Get() >= 90 }, score),
    p.P("Grade: A"),
).ElseIf(p.Cond(func() bool { return score.Get() >= 70 }, score),
    p.P("Grade: C"),
).Else(
    p.P("Grade: F"),
)

p.Cond(fn, ...stores) pairs a boolean function with the stores it depends on so the framework knows when to re-evaluate.

Lists

type Todos struct {
    Items   *p.List[string]
    NewItem *p.Store[string]
}

func (t *Todos) New() p.Component {
    return &Todos{
        Items:   p.NewList[string]("Buy milk", "Write code"),
        NewItem: p.New(""),
    }
}

func (t *Todos) Add() {
    if item := t.NewItem.Get(); item != "" {
        t.Items.Append(item)
        t.NewItem.Set("")
    }
}

func (t *Todos) Render() p.Node {
    return p.Div(
        p.Input(p.Attr("type", "text")).Bind(t.NewItem),
        p.Button("Add").On("click", t.Add),
        p.Ul(
            p.Each(t.Items, func(item string, i int) p.Node {
                return p.Li(item)
            }),
        ),
    )
}

p.NewList creates a reactive slice. p.Each renders each item. The list re-renders when items change.

Components with Props and Slots

Define a reusable component:

type Card struct {
    Title *p.Store[string]
}

func (c *Card) Render() p.Node {
    return p.Div(p.Attr("class", "card"),
        p.H2(c.Title),
        p.Div(p.Slot()),
    )
}

Use it:

p.Comp(&Card{Title: p.New("Welcome")},
    p.P("This content fills the slot."),
)

Props are struct fields. p.Slot() renders child content passed to p.Comp().

Component Events (Callbacks)

Pass functions as props for child-to-parent communication:

type Button struct {
    Label   *p.Store[string]
    OnClick func()
}

func (b *Button) Render() p.Node {
    return p.Button(b.Label).On("click", b.OnClick)
}

// parent usage:
p.Comp(&Button{Label: p.New("Save"), OnClick: func() {
    status.Set("Saved!")
}})

Scoped CSS

Return CSS from Style() and it's automatically scoped to the component:

func (c *Card) Style() string {
    return `.card { border: 1px solid #ddd; padding: 16px; border-radius: 8px; }`
}

No class name collisions across components.

Conditional Attributes

darkMode := p.New(false)

p.Div("content").AttrIf("class",
    p.Cond(func() bool { return darkMode.Get() }, darkMode),
    "dark",
)

When darkMode is true, the dark class is added. When false, it's removed.

Derived Stores

Compute values from other stores:

func Derived1[A, R any](a *p.Store[A], fn func(A) R) *p.Store[R] {
    out := p.New(fn(a.Get()))
    a.OnChange(func(_ A) { out.Set(fn(a.Get())) })
    return out
}

name := p.New("hello")
upper := Derived1(name, strings.ToUpper) // auto-updates when name changes

Fetching Data

Typed HTTP client with automatic JSON encoding/decoding:

type User struct {
    ID   int    `js:"id"`
    Name string `js:"name"`
}

func (c *MyComponent) OnMount() {
    if p.IsBuildTime {
        return // skip during SSR
    }
    go func() {
        user, err := p.Get[User]("/api/user/1")
        if err != nil {
            return
        }
        c.UserName.Set(user.Name)
    }()
}

Also available: p.Post[T], p.Put[T], p.Patch[T], p.Delete[T].

Routing

type App struct {
    CurrentPage *p.Store[p.Component]
}

func (a *App) Routes() []p.Route {
    return []p.Route{
        {Path: "/", HTMLFile: "index.html", SSRPath: "/", Component: &Home{}},
        {Path: "/about", HTMLFile: "about.html", SSRPath: "/about", Component: &About{}},
    }
}

func (a *App) OnMount() {
    router := p.NewRouter(a.CurrentPage, a.Routes(), "app")
    router.Start()
}

func (a *App) Render() p.Node {
    return p.Div(
        p.Nav(
            p.A(p.Attr("href", "/"), "Home"),
            p.A(p.Attr("href", "/about"), "About"),
        ),
        p.Main(a.CurrentPage),
    )
}

Internal <a> links are automatically intercepted for SPA navigation. Add the external attribute to opt out.

LocalStorage

// auto-persists on every .Set()
theme := p.NewLocalStore("theme", "light")
theme.Set("dark") // saved to localStorage immediately

// manual localStorage API
p.SetStorage("key", "value")
val := p.GetStorage("key")
p.RemoveStorage("key")

Lifecycle

Interface Method When
HasNew New() Component Factory -- create stores and child components here
HasOnMount OnMount() Component becomes active (fetch data, start timers)
HasOnDestroy OnDestroy() Component removed (cleanup)
HasStyle Style() string Scoped CSS for this component
HasGlobalStyle GlobalStyle() string Global CSS (unscoped)

Timers

stop := p.SetInterval(1000, func() { /* runs every second */ })
defer stop()

cancel := p.SetTimeout(3000, func() { /* runs once after 3s */ })

debounced, cleanup := p.Debounce(300, handler)
defer cleanup()

Build

Output goes to dist/ -- serve with any static file server.

dist/
  index.html     # pre-rendered HTML
  main.wasm      # compiled WASM binary
  wasm_exec.js   # Go WASM runtime

Architecture

Both SSR (native Go at build time) and WASM (browser at runtime) execute the same component code. SSR pre-renders HTML with comment markers and element IDs. WASM walks the same Render() tree to discover bindings and wire them to the existing DOM. No intermediate binary format, no code generation -- just a direct tree walk.

The critical invariant: SSR and WASM must create stores and register handlers in identical order so counter-based IDs match between pre-rendered HTML and the live WASM runtime.


History

PrevelteKit went through several architectural stages on the way to 2.0. The core philosophy stayed the same throughout: minimal framework, static HTML output, clear separation between frontend and backend.

1.x -- Svelte/TypeScript

The original PrevelteKit was a minimalistic (~500 LoC) web framework built on Svelte 5, using Rsbuild as the bundler and jsdom for build-time pre-rendering. Components were standard Svelte files:

<script>
    let count = $state(0);
</script>

<h1>Count: {count}</h1>
<button onclick={() => count++}>Click me</button>

The motivation was simple: SvelteKit is powerful but heavy. PrevelteKit offered build-time pre-rendering without the meta-framework complexity. The output was purely static assets -- HTML, CSS, JS -- deployable to any CDN or web server with no server runtime required.

This version worked well, but the dependency on the JavaScript ecosystem (Node.js, npm, bundlers) remained a friction point. The idea of writing the entire frontend in Go and compiling to WASM started to take shape.

1.9.1 -- Go/WASM with DSL and Code Generation

The first Go rewrite introduced a Svelte-inspired template DSL. Components had a Template() method returning a string with special syntax:

func (c *Counter) Template() string {
    return `<div>
        <p>Count: {Count}</p>
        <button @click="Increment()">+1</button>
        {#if Count > 10}
            <p>That's a lot!</p>
        {:else}
            <p>Keep clicking</p>
        {/if}
    </div>`
}

A build step parsed these templates and generated Go code -- transforming {Count} into store reads, @click into handler registrations, {#if} / {#each} into conditional/iteration logic. The generated code was then compiled to WASM.

This approach worked but had significant drawbacks:

  • A custom parser and code generator added complexity and maintenance burden
  • Template errors surfaced at generation time, not compile time -- debugging was indirect
  • The generated Go code was hard to read and harder to debug
  • Two languages in one file (Go + template DSL) felt awkward

1.9.2 -- Bindings Binary

The next iteration removed the template DSL in favor of writing UI trees directly in Go. But it introduced a different separation: SSR rendered HTML at build time, and a bindings.bin file was generated to tell the WASM runtime where all the reactive bindings, event handlers, and dynamic blocks lived. The WASM binary didn't contain any HTML -- it only read the bindings file and wired up interactivity.

This reduced WASM binary size since no HTML strings were compiled in, but added its own complexity:

  • A custom binary format had to be designed, serialized at build time, and deserialized at runtime
  • The bindings file was another artifact to generate, serve, and keep in sync
  • Any mismatch between the HTML and the bindings file caused subtle, hard-to-diagnose bugs
  • The indirection made the system harder to reason about

Many intermediate prototypes were built and discarded between 1.9.1 and 1.9.2 (and between 1.9.2 and 2.0), often with the help of LLMs for rapid exploration of different approaches.

2.0 -- Direct Tree Walk

The current version eliminates both code generation and the bindings binary. Components define their UI with typed Go functions — Div(), Span(), Button(), If(), Each(), Comp(), etc. The same Render() method runs at build time (native Go, SSR) and at runtime (WASM, hydration). Both walks advance the same global counters in the same order, so comment markers and element IDs match without any intermediate format.

What changed:

  • No code generation -- the Go DSL is plain Go, checked by the compiler
  • No bindings.bin -- WASM discovers bindings by walking the same tree SSR walked
  • No template language -- conditionals, loops, and components are Go function calls
  • Typed elements -- Div(), P(), Input() etc. with structured rendering, no HTML string parsing
  • Simpler mental model -- one Render() method, two execution contexts

The tradeoff is that WASM binaries include HTML string literals, making them slightly larger. In practice (~60kb gzipped) this is acceptable.

What started as ~500 lines of glue code between Svelte, jsdom, and Rsbuild is now a ~4k LoC self-contained framework with no external dependencies beyond the Go standard library and the WASM runtime.

Why Static Output?

Throughout all versions, the preference has been clear separation: the frontend is static assets (HTML/CSS/JS or HTML/CSS/WASM) served from any CDN. The backend is a separate service with /api endpoints. No server-side rendering runtime, no Node.js in production, no blurred boundaries between view code and server code.

Meta-frameworks like Next.js, Nuxt, and SvelteKit blur this separation by requiring a JavaScript runtime for SSR, API routes, and build-time generation. Serving just static content is simpler: deploy anywhere (GitHub Pages, S3, any web server) with predictable performance.

Classic SSR (Next.js, Nuxt): Server renders HTML on every request. Requires a runtime.

SPA (React, Vue): Browser renders everything. User sees a blank page until JS loads.

Build-time Pre-rendering (PrevelteKit): HTML is rendered once at build time. User sees content instantly. WASM hydrates for interactivity. No server runtime needed.

Inspiration

License

MIT

Top categories

Loading Svelte Themes