diff --git a/README.md b/README.md
index c7215dd..2f77311 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@ All examples follow the [progressive complexity](https://github.com/livetemplate
| `live-preview/` | 1 | Change() live updates | None |
| `login/` | 1 | Authentication + sessions | None |
| `shared-notepad/` | 1 | BasicAuth + SharedState | None |
+| `ephemeral-counter/` | 1 | Ephemeral state with in-memory DB | None |
+| `ephemeral-todos/` | 1 | Ephemeral state with SQLite DB | None |
## Examples
diff --git a/ephemeral-counter/counter.tmpl b/ephemeral-counter/counter.tmpl
new file mode 100644
index 0000000..7796507
--- /dev/null
+++ b/ephemeral-counter/counter.tmpl
@@ -0,0 +1,31 @@
+
+
+
+ Ephemeral Counter
+
+
+
+
+
+
+
+
+ Count: {{.Count}}
+
+
+
+
+
+
+
+
+ {{if .lvt.DevMode}}
+
+ {{else}}
+
+ {{end}}
+
+
diff --git a/ephemeral-counter/counter_test.go b/ephemeral-counter/counter_test.go
new file mode 100644
index 0000000..b582133
--- /dev/null
+++ b/ephemeral-counter/counter_test.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+ e2etest "github.com/livetemplate/lvt/testing"
+)
+
+func TestMain(m *testing.M) {
+ e2etest.CleanupChromeContainers()
+ code := m.Run()
+ e2etest.CleanupChromeContainers()
+ os.Exit(code)
+}
+
+func TestEphemeralCounterE2E(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping E2E test in short mode")
+ }
+
+ serverPort, err := e2etest.GetFreePort()
+ if err != nil {
+ t.Fatalf("Failed to get free port for server: %v", err)
+ }
+
+ debugPort, err := e2etest.GetFreePort()
+ if err != nil {
+ t.Fatalf("Failed to get free port for Chrome: %v", err)
+ }
+
+ serverCmd := e2etest.StartTestServer(t, "main.go", serverPort)
+ defer func() {
+ if serverCmd != nil && serverCmd.Process != nil {
+ serverCmd.Process.Kill()
+ }
+ }()
+
+ chromeCmd := e2etest.StartDockerChrome(t, debugPort)
+ defer e2etest.StopDockerChrome(t, debugPort)
+ _ = chromeCmd
+
+ chromeURL := fmt.Sprintf("http://localhost:%d", debugPort)
+ allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL)
+ defer allocCancel()
+
+ ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf))
+ defer cancel()
+
+ ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
+ defer cancel()
+
+ t.Run("Initial_Load", func(t *testing.T) {
+ var html string
+ err := chromedp.Run(ctx,
+ chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
+ e2etest.WaitForWebSocketReady(5*time.Second),
+ chromedp.WaitVisible(`h1`, chromedp.ByQuery),
+ e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"),
+ chromedp.OuterHTML(`body`, &html, chromedp.ByQuery),
+ )
+ if err != nil {
+ t.Fatalf("Failed to load page: %v", err)
+ }
+ if !strings.Contains(html, "Count: 0") {
+ t.Error("Expected initial Count: 0")
+ }
+ })
+
+ t.Run("Increment", func(t *testing.T) {
+ err := chromedp.Run(ctx,
+ e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second),
+ chromedp.Evaluate(`document.querySelector('button[name="increment"]').click()`, nil),
+ e2etest.WaitFor(`document.body.innerText.includes('Count: 1')`, 5*time.Second),
+ )
+ if err != nil {
+ t.Fatalf("Failed to increment: %v", err)
+ }
+ })
+
+ t.Run("Ephemeral_State_Survives_Refresh_Via_DB", func(t *testing.T) {
+ // Ephemeral mode: session state is NOT persisted, but the in-memory "DB"
+ // retains the count. On refresh, Mount() reloads from DB, so count = 1.
+ err := chromedp.Run(ctx,
+ chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
+ e2etest.WaitForWebSocketReady(5*time.Second),
+ chromedp.WaitVisible(`h1`, chromedp.ByQuery),
+ )
+ if err != nil {
+ t.Fatalf("Failed to reload: %v", err)
+ }
+
+ var html string
+ if err := chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)); err != nil {
+ t.Fatalf("Failed to get HTML: %v", err)
+ }
+
+ if !strings.Contains(html, "Count: 1") {
+ t.Errorf("Expected Count: 1 after refresh (loaded from DB), got: %s", html)
+ }
+ })
+}
diff --git a/ephemeral-counter/main.go b/ephemeral-counter/main.go
new file mode 100644
index 0000000..65c022c
--- /dev/null
+++ b/ephemeral-counter/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/livetemplate/livetemplate"
+ e2etest "github.com/livetemplate/lvt/testing"
+)
+
+// CounterDB is a thread-safe in-memory counter that acts as the "database".
+// With WithEphemeralState(), LiveTemplate state is rebuilt from this store
+// on every request — no session persistence needed.
+type CounterDB struct {
+ mu sync.Mutex
+ value int
+}
+
+func (db *CounterDB) Get() int {
+ db.mu.Lock()
+ defer db.mu.Unlock()
+ return db.value
+}
+
+func (db *CounterDB) Add(delta int) int {
+ db.mu.Lock()
+ defer db.mu.Unlock()
+ db.value += delta
+ return db.value
+}
+
+func (db *CounterDB) Reset() {
+ db.mu.Lock()
+ defer db.mu.Unlock()
+ db.value = 0
+}
+
+type CounterController struct {
+ DB *CounterDB
+}
+
+type CounterState struct {
+ Count int `json:"count"`
+}
+
+func (c *CounterController) Mount(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
+ state.Count = c.DB.Get()
+ return state, nil
+}
+
+func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
+ state.Count = c.DB.Add(1)
+ return state, nil
+}
+
+func (c *CounterController) Decrement(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
+ state.Count = c.DB.Add(-1)
+ return state, nil
+}
+
+func (c *CounterController) Reset(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
+ c.DB.Reset()
+ state.Count = 0
+ return state, nil
+}
+
+func main() {
+ controller := &CounterController{DB: &CounterDB{}}
+
+ tmpl := livetemplate.Must(livetemplate.New("counter"))
+ handler := tmpl.Handle(controller, livetemplate.AsState(&CounterState{}),
+ livetemplate.WithEphemeralState(),
+ )
+
+ mux := http.NewServeMux()
+ mux.Handle("/", handler)
+ mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
+
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+
+ server := &http.Server{
+ Addr: ":" + port,
+ Handler: mux,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ }
+
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ slog.Info("Server starting", "url", "http://localhost:"+port)
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ slog.Error("Server failed", "error", err)
+ os.Exit(1)
+ }
+ }()
+
+ <-quit
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := server.Shutdown(ctx); err != nil {
+ slog.Error("Shutdown error", "error", err)
+ }
+}
diff --git a/ephemeral-todos/main.go b/ephemeral-todos/main.go
new file mode 100644
index 0000000..0d7e559
--- /dev/null
+++ b/ephemeral-todos/main.go
@@ -0,0 +1,167 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "strconv"
+ "syscall"
+ "time"
+
+ "github.com/livetemplate/livetemplate"
+ e2etest "github.com/livetemplate/lvt/testing"
+ _ "modernc.org/sqlite"
+)
+
+type Todo struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Done bool `json:"done"`
+}
+
+// TodoController holds the database dependency.
+type TodoController struct {
+ DB *sql.DB
+}
+
+// TodoState is rebuilt from the database on every request.
+type TodoState struct {
+ Items []Todo `json:"items"`
+}
+
+func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
+ rows, err := c.DB.Query("SELECT id, title, done FROM todos ORDER BY id")
+ if err != nil {
+ return state, err
+ }
+ defer rows.Close()
+
+ state.Items = nil
+ for rows.Next() {
+ var t Todo
+ if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil {
+ return state, err
+ }
+ state.Items = append(state.Items, t)
+ }
+ return state, nil
+}
+
+func (c *TodoController) Submit(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
+ title := ctx.GetString("title")
+ if title == "" {
+ return state, livetemplate.FieldError{Field: "title", Message: "Title is required"}
+ }
+
+ result, err := c.DB.Exec("INSERT INTO todos (title, done) VALUES (?, 0)", title)
+ if err != nil {
+ return state, err
+ }
+
+ id, _ := result.LastInsertId()
+ state.Items = append(state.Items, Todo{ID: int(id), Title: title, Done: false})
+ return state, nil
+}
+
+func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
+ id, _ := strconv.Atoi(ctx.GetString("value"))
+ _, err := c.DB.Exec("UPDATE todos SET done = NOT done WHERE id = ?", id)
+ if err != nil {
+ return state, err
+ }
+
+ for i := range state.Items {
+ if state.Items[i].ID == id {
+ state.Items[i].Done = !state.Items[i].Done
+ break
+ }
+ }
+ return state, nil
+}
+
+func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
+ id, _ := strconv.Atoi(ctx.GetString("value"))
+ _, err := c.DB.Exec("DELETE FROM todos WHERE id = ?", id)
+ if err != nil {
+ return state, err
+ }
+
+ for i := range state.Items {
+ if state.Items[i].ID == id {
+ state.Items = append(state.Items[:i], state.Items[i+1:]...)
+ break
+ }
+ }
+ return state, nil
+}
+
+func initDB() *sql.DB {
+ db, err := sql.Open("sqlite", "file:todos.db?cache=shared&mode=rwc")
+ if err != nil {
+ slog.Error("Failed to open database", "error", err)
+ os.Exit(1)
+ }
+ _, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ done BOOLEAN NOT NULL DEFAULT 0
+ )`)
+ if err != nil {
+ slog.Error("Failed to create table", "error", err)
+ os.Exit(1)
+ }
+ return db
+}
+
+func main() {
+ db := initDB()
+ defer db.Close()
+
+ controller := &TodoController{DB: db}
+
+ opts := []livetemplate.Option{}
+ if os.Getenv("LVT_WEBSOCKET_DISABLED") == "true" {
+ opts = append(opts, livetemplate.WithWebSocketDisabled())
+ }
+
+ tmpl := livetemplate.Must(livetemplate.New("todos", opts...))
+ handler := tmpl.Handle(controller, livetemplate.AsState(&TodoState{}),
+ livetemplate.WithEphemeralState(),
+ )
+
+ mux := http.NewServeMux()
+ mux.Handle("/", handler)
+ mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
+
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+
+ server := &http.Server{
+ Addr: ":" + port,
+ Handler: mux,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ }
+
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ slog.Info("Server starting", "url", "http://localhost:"+port)
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ slog.Error("Server failed", "error", err)
+ os.Exit(1)
+ }
+ }()
+
+ <-quit
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ server.Shutdown(ctx)
+}
diff --git a/ephemeral-todos/todos.tmpl b/ephemeral-todos/todos.tmpl
new file mode 100644
index 0000000..49329a9
--- /dev/null
+++ b/ephemeral-todos/todos.tmpl
@@ -0,0 +1,51 @@
+
+
+
+ Ephemeral Todos
+
+
+
+
+
+
+
+
+
+
+
+ {{if .Items}}
+
+ {{else}}
+ No todos yet. Add one above!
+ {{end}}
+
+
+
+ {{if .lvt.DevMode}}
+
+ {{else}}
+
+ {{end}}
+
+
diff --git a/ephemeral-todos/todos_test.go b/ephemeral-todos/todos_test.go
new file mode 100644
index 0000000..e36efe3
--- /dev/null
+++ b/ephemeral-todos/todos_test.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+ e2etest "github.com/livetemplate/lvt/testing"
+)
+
+func TestMain(m *testing.M) {
+ e2etest.CleanupChromeContainers()
+ code := m.Run()
+ e2etest.CleanupChromeContainers()
+ os.Exit(code)
+}
+
+func TestEphemeralTodosE2E(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping E2E test in short mode")
+ }
+
+ serverPort, err := e2etest.GetFreePort()
+ if err != nil {
+ t.Fatalf("Failed to get free port for server: %v", err)
+ }
+
+ debugPort, err := e2etest.GetFreePort()
+ if err != nil {
+ t.Fatalf("Failed to get free port for Chrome: %v", err)
+ }
+
+ serverCmd := e2etest.StartTestServer(t, "main.go", serverPort)
+ defer func() {
+ if serverCmd != nil && serverCmd.Process != nil {
+ serverCmd.Process.Kill()
+ }
+ }()
+
+ chromeCmd := e2etest.StartDockerChrome(t, debugPort)
+ defer e2etest.StopDockerChrome(t, debugPort)
+ _ = chromeCmd
+
+ chromeURL := fmt.Sprintf("http://localhost:%d", debugPort)
+ allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL)
+ defer allocCancel()
+
+ ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf))
+ defer cancel()
+
+ ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
+ defer cancel()
+
+ t.Run("Initial_Load_Empty", func(t *testing.T) {
+ var html string
+ err := chromedp.Run(ctx,
+ chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
+ e2etest.WaitForWebSocketReady(5*time.Second),
+ chromedp.WaitVisible(`h1`, chromedp.ByQuery),
+ e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"),
+ chromedp.OuterHTML(`body`, &html, chromedp.ByQuery),
+ )
+ if err != nil {
+ t.Fatalf("Failed to load page: %v", err)
+ }
+ if !strings.Contains(html, "No todos yet") {
+ t.Error("Expected empty state message")
+ }
+ })
+
+ t.Run("Add_Todo", func(t *testing.T) {
+ err := chromedp.Run(ctx,
+ e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second),
+ chromedp.SetValue(`input[name="title"]`, "Buy groceries", chromedp.ByQuery),
+ chromedp.Evaluate(`document.querySelector('button[type="submit"]').click()`, nil),
+ e2etest.WaitFor(`document.body.innerText.includes('Buy groceries')`, 5*time.Second),
+ )
+ if err != nil {
+ t.Fatalf("Failed to add todo: %v", err)
+ }
+ })
+
+ t.Run("Todo_Survives_Refresh_Via_DB", func(t *testing.T) {
+ // Ephemeral mode: session state is NOT persisted, but SQLite retains the
+ // todo. On refresh, Mount() reloads from DB.
+ err := chromedp.Run(ctx,
+ chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
+ e2etest.WaitForWebSocketReady(5*time.Second),
+ chromedp.WaitVisible(`h1`, chromedp.ByQuery),
+ )
+ if err != nil {
+ t.Fatalf("Failed to reload: %v", err)
+ }
+
+ var html string
+ if err := chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)); err != nil {
+ t.Fatalf("Failed to get HTML: %v", err)
+ }
+
+ if !strings.Contains(html, "Buy groceries") {
+ t.Errorf("Expected 'Buy groceries' after refresh (loaded from DB), got: %s", html)
+ }
+ })
+}
diff --git a/go.mod b/go.mod
index 9a8b90c..210b8f4 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
- github.com/livetemplate/livetemplate v0.8.10
+ github.com/livetemplate/livetemplate v0.8.11
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e
github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e
modernc.org/sqlite v1.43.0
diff --git a/go.sum b/go.sum
index c7103c2..320bcde 100644
--- a/go.sum
+++ b/go.sum
@@ -94,6 +94,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/livetemplate/livetemplate v0.8.10 h1:Lg62gb297Iq3/EShPnEUyqDFN8aeyTYchbW3wUFU370=
github.com/livetemplate/livetemplate v0.8.10/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
+github.com/livetemplate/livetemplate v0.8.11 h1:s7yFfHp53tv5W7WNViQUK7EBq6ZmBth+mGA34+/zL9s=
+github.com/livetemplate/livetemplate v0.8.11/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e h1:nAV7BaOatFcbSaP6m9CgLrRsGPeWtbjPDZ8dOI6Zb+c=
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU=
github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e h1:vuR0pQtEQHZOD2/HvTJfHPKEdoD77XUs1mq1kdjgVig=
diff --git a/test-all.sh b/test-all.sh
index ae85c0d..33a3a9d 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -36,6 +36,8 @@ WORKING_EXAMPLES=(
"todos-components"
"shared-notepad"
"flash-messages"
+ "ephemeral-counter"
+ "ephemeral-todos"
)
# Disabled examples