Caution
Very Beta - This is experimental software. The API changes frequently and will break your code. Don't use this for anything production-critical. Great for prototypes, internal tools, and fun real-time multiplayer apps.
Server-driven UI for real-time web apps. No client code. No state sync. Just TypeScript + JSX.
A multiplayer territory control game in ~300 lines of code. Click cells to claim them for your team - all players see changes in real-time.
Inspired by Phoenix LiveView | Datastar | HTMX
Tip
Built for Vibe Coding - JSX that feels like React, but there's no client bundle, no hydration, no state sync bugs. The server owns everything. Live, realtime UI, directly from the server. When you use bunx hyperstar-cli create, your project includes a Claude Code skill that teaches Claude how to build Hyperstar apps. If you're not using the CLI, you can copy the skill from packages/cli/skill/SKILL.md into your project's .claude/skills/ directory.
bunx hyperstar-cli create my-app
cd my-app
bun install
bun run devOpen http://localhost:8080 - you have a working app.
Now edit app.tsx:
import{createHyperstar,hs}from"hyperstar"interfaceStore{count: number}constapp=createHyperstar<Store>()constincrement=app.action("increment",(ctx)=>{ctx.update((s)=>({ ...s,count: s.count+1}))})app.app({store: {count: 0},view: (ctx)=>(<divid="app"><h1>{ctx.store.count}</h1><button$={hs.action(increment)}>+1</button></div>),}).serve({port: 8080})Open multiple tabs - they all sync in real-time.
Ready to ship? Deploy to Fly.io Sprites:
bunx hyperstar-cli deploy --managedYour app is live. No Docker, no config, no CI/CD setup.
Zero client code. Your entire app lives on the server. No React components, no client-side state management, no API routes to wire up. Just TypeScript functions that update state and JSX that renders it.
Real-time by default. Every state change automatically syncs to all connected clients. User A clicks a button, User B sees it instantly. No WebSocket setup, no pub/sub configuration, no cache invalidation.
One mental model. Server state is the source of truth. No wondering if the client is out of sync, no optimistic updates gone wrong, no race conditions between tabs.
Ship faster. Internal tools, prototypes, multiplayer games, live dashboards - build them in seconds instead of hours. When the server owns everything, there's just less to think about.
bun add hyperstar- Server renders HTML from your
viewfunction - Clients connect via SSE (Server-Sent Events)
- When state changes, server re-renders and streams HTML
- Clients morph the DOM - no full page reload
Real-time means all clients see the same state. User A clicks a button, User B sees it instantly.
Hyperstar has three types of state, each serving a different purpose:
┌─────────────────────────────────────────────────────────────────┐
│ STORE (Global Server State) │
│ • Shared across ALL connected clients │
│ • User A adds item → User B sees it instantly │
│ • Use for: shared data, chat messages, game state │
├─────────────────────────────────────────────────────────────────┤
│ USERSTORE (Per-Session Server State) │
│ • Private to each browser session │
│ • Persists on server across page reloads │
│ • Use for: user preferences, theme, auth state │
├─────────────────────────────────────────────────────────────────┤
│ SIGNALS (Client State) │
│ • Lives in browser memory only │
│ • Instant updates, no server roundtrip │
│ • Server can update via ctx.patchSignals() │
│ • Use for: form inputs, UI tabs, modals, hover state │
└─────────────────────────────────────────────────────────────────┘
// All three in actionconstapp=createHyperstar<Store,UserStore,Signals>()// Global: everyone sees thisconstaddMessage=app.action("addMessage",{text: Schema.String},(ctx,{ text })=>{ctx.update((s)=>({ ...s,messages: [...s.messages,text]}))})// Per-user: only this session sees thisconstsetTheme=app.action("setTheme",{theme: Schema.String},(ctx,{ theme })=>{ctx.updateUserStore((u)=>({ ...u, theme }))})// Client-side: instant UI state (server can also update via ctx.patchSignals)const{ tab }=app.signals<buttonhs-on:click="$tab.value = 'settings'">Settings</button>Actions modify state. Changes broadcast to all clients.
// Simple actionconstreset=app.action("reset",(ctx)=>{ctx.update((s)=>({ ...s,count: 0}))})// Action with validated argumentsconstadd=app.action("add",{amount: Schema.Number},(ctx,{ amount })=>{ctx.update((s)=>({ ...s,count: s.count+amount}))})Use hs.* helpers with the $ prop for reactive behavior:
// Trigger action on click<button$={hs.action(increment)}>+1</button>// Trigger action on a specific event (with modifiers)<input$={hs.actionOn("input",search,{q: query},{debounce: 200})}/>// Form submission<form$={hs.form(addTodo)}><inputname="text"$={hs.bind(text)}/><buttontype="submit">Add</button></form>// Conditional visibility<div$={hs.show(isVisible)}>Onlyshownwhenvisible</div>// Dynamic classes<div$={hs.class("active",isActive)}>...</div>Signals are client-side state - private to each browser tab, instant updates, no server roundtrip.
interfaceSignals{tab: "home"|"settings"text: string}constapp=createHyperstar<Store,{},Signals>()const{ tab, text }=app.signalsapp.app({store: { ... },signals: {tab: "home",text: ""},view: (ctx)=>(<divid="app">{/* Instant tab switching - no server call */}<buttonhs-on:click="$tab.value = 'home'">Home</button><buttonhs-on:click="$tab.value = 'settings'">Settings</button>{/* Show/hide based on signal */}<divhs-show={tab.is("home")}>Home content</div><divhs-show={tab.is("settings")}>Settings content</div></div>),})git clone https://github.com/StreamUI/hyperstar
cd hyperstar && bun install
bun --hot examples/counter.tsx # Basic counter
bun --hot examples/todos.tsx # Full todo app
bun --hot examples/chat-room.tsx # Multi-user chat
bun --hot examples/dsl-showcase.tsx # DSL helpers showcase
bun --hot examples/dashboard.tsx # Live metrics dashboard
bun --hot examples/sqlite-notes.tsx # SQLite persistence
bun --hot examples/state-types.tsx # Store vs UserStore vs Signals
bun --hot examples/fps.tsx # 60fps game loop
bun --hot examples/tron/app.tsx # Multiplayer TronHyperstar apps are just Bun servers - deploy anywhere you can run bun run app.tsx. The CLI has built-in support for Fly.io Sprites, lightweight VMs that hibernate when idle.
Create a hyperstar.json in your project root:
{
"name": "my-app",
"entrypoint": "app.tsx"
}Warning
Managed hosting is for quick testing only. It may be taken down at any time without notice. Do not deploy anything critical. Use your own Sprites token for persistent deployments.
bunx hyperstar-cli deploy --managedNo account needed. Deploy instantly.
For full control, use your own Fly.io Sprites token:
export SPRITE_TOKEN=your_token
bunx hyperstar-cli deployNote
Sprites and Background Tasks - Sprites hibernate when idle to save costs. When a sprite sleeps, app.repeat() and app.cron() timers pause. They resume when a user reconnects. For apps that need always-on timers (like polling external APIs), deploy to a traditional always-on server instead.
Since Hyperstar apps are standard Bun servers, deploy anywhere:
# Any VM/VPS
bun install && bun run app.tsx
# Docker
FROM oven/bun
COPY ..
RUN bun install
CMD ["bun", "run", "app.tsx"]Actions can be async for API calls, streaming, etc:
constfetchData=app.action("fetchData",async(ctx)=>{ctx.update((s)=>({ ...s,loading: true}))constdata=awaitfetch("/api/data").then((r)=>r.json())ctx.update((s)=>({ ...s, data,loading: false}))})Hyperstar has two ways to run background tasks. Choose based on your use case:
| Type | Best For | Key Feature |
|---|---|---|
| Repeat | Games, animations, heartbeats, polling | Conditional execution + FPS tracking |
| Cron | Scheduled jobs | Cron expressions + per-user handlers |
Repeat - Time-based repeating tasks (replaces timer + interval):
// Game loop with FPS trackingapp.repeat("gameLoop",{every: 16,// ~60fps in millisecondswhen: (s)=>s.running,// Only run when true (pause/resume)trackFps: true,// Enables ctx.fpshandler: (ctx)=>{ctx.update((s)=>({ ...s,frame: s.frame+1,fps: ctx.fps}))},})// Simple heartbeatapp.repeat("heartbeat",{every: "5 seconds",// Duration stringhandler: (ctx)=>{ctx.update((s)=>({ ...s,lastPing: Date.now()}))},})// Duration formats:app.repeat("fast",{every: 100, ... })// 100ms (number)app.repeat("poll",{every: "500 millis", ... })// 500msapp.repeat("sync",{every: "30 seconds", ... })// 30sapp.repeat("refresh",{every: "5 minutes", ... })// 5mapp.repeat("report",{every: "1 hour", ... })// 1hCron - Scheduled jobs with calendar-based timing:
app.cron("cleanup",{every: "0 * * * *",// Every hour (cron syntax)handler: (ctx)=>{ctx.update((s)=>({ ...s,messages: s.messages.slice(-100)}))},})// Or run per-user (great for session cleanup):app.cron("sessionSync",{every: "30 seconds",// Also accepts duration stringsforEachUser: (ctx)=>{ctx.updateUser((u)=>({ ...u,lastSeen: Date.now()}))},})app.app({store: {online: 0},onStart: (ctx)=>{// Called once when server startsconsole.log("Server started")},onConnect: (ctx)=>{// Called when a client connectsctx.update((s)=>({ ...s,online: s.online+1}))},onDisconnect: (ctx)=>{// Called when a client disconnectsctx.update((s)=>({ ...s,online: s.online-1}))},view: (ctx)=> ...
})Auto-save store to JSON:
app.app({store: {todos: []},persist: "./data/todos.json",view: (ctx)=> ...
})Since Hyperstar runs on a single server (perfect for lightweight VMs like Sprites), you can read/write directly to disk. No external database needed:
import{Database}from"bun:sqlite"import{createHyperstar,hs,Schema}from"hyperstar"constdb=newDatabase("./data/notes.db")db.run(`CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')))`)interfaceStore{refresh: number}constapp=createHyperstar<Store>()constaddNote=app.action("addNote",{title: Schema.String},(ctx,{ title })=>{db.run("INSERT INTO notes (id, title) VALUES (?, ?)",[crypto.randomUUID(),title])ctx.update((s)=>({ ...s,refresh: s.refresh+1}))// Trigger re-render})app.app({store: {refresh: 0},view: (ctx)=>{constnotes=db.query("SELECT * FROM notes ORDER BY created_at DESC").all()return(<divid="app"><ul>{notes.map((n: any)=><liid={n.id}>{n.title}</li>)}</ul></div>)},}).serve({port: 8080})No ORM, no connection pooling, no Redis - just bun:sqlite. This works because:
- Hyperstar apps run on a single server instance
- Bun's SQLite is synchronous and fast
app.app({store: {unreadCount: 0,status: "idle"},// Dynamic title with notification badgetitle: ({ store })=>store.unreadCount>0 ? `(${store.unreadCount}) My App` : "My App",// Dynamic favicon based on statusfavicon: ({ store })=>store.status==="active"
? "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🟢</text></svg>"
: "/favicon.ico",view: (ctx)=> ...
})// Or update programmatically in actions:constnotify=app.action("notify",(ctx)=>{ctx.head.setTitle("New message!")ctx.head.setFavicon("/alert.ico")})Signals have typed methods that compose:
const{ filter, count, isOpen }=app.signals// Methods based on typefilter.is("active")// "$filter.value === 'active'"count.gt(5)// "$count.value > 5"isOpen.toggle()// "$isOpen.value = !$isOpen.value"// Compositionfilter.is("active").and(count.gt(0))filter.is("all").or(isOpen)isOpen.not()State that's private to each session but persists on the server:
interfaceUserStore{theme: "light"|"dark"}constapp=createHyperstar<Store,UserStore,Signals>()constsetTheme=app.action("setTheme",{theme: Schema.String},(ctx,{ theme })=>{ctx.updateUserStore((u)=>({ ...u, theme }))})app.app({store: { ... },userStore: {theme: "light"},view: (ctx)=>(<divclass={ctx.userStore.theme==="dark" ? "dark" : ""}>...</div>),})A timer that counts down and stops at zero:
import{createHyperstar,hs}from"hyperstar"interfaceStore{timeLeft: numberrunning: boolean}constapp=createHyperstar<Store>()conststart=app.action("start",(ctx)=>{ctx.update((s)=>({ ...s,running: true,timeLeft: 60}))})conststop=app.action("stop",(ctx)=>{ctx.update((s)=>({ ...s,running: false}))})app.repeat("countdown",{every: "1 second",when: (s)=>s.running&&s.timeLeft>0,handler: (ctx)=>{ctx.update((s)=>{constnewTime=s.timeLeft-1return{ ...s,timeLeft: newTime,running: newTime>0}})},})app.app({store: {timeLeft: 60,running: false},view: (ctx)=>(<divid="app"class="p-8 text-center"><divclass="text-6xl font-mono mb-4">{ctx.store.timeLeft}s</div>{!ctx.store.running ? (<button$={hs.action(start)}class="px-4 py-2 bg-green-500 text-white rounded">
Start
</button>) : (<button$={hs.action(stop)}class="px-4 py-2 bg-red-500 text-white rounded">
Stop
</button>)}</div>),}).serve({port: 8080})Fetch data from an external API on an interval:
import{createHyperstar}from"hyperstar"interfaceStore{price: number|nulllastUpdated: string|nullerror: string|null}constapp=createHyperstar<Store>()app.repeat("fetchPrice",{every: "10 seconds",handler: async(ctx)=>{try{constres=awaitfetch("https://api.example.com/price")const{ price }=awaitres.json()ctx.update((s)=>({
...s,
price,lastUpdated: newDate().toISOString(),error: null,}))}catch(e){ctx.update((s)=>({ ...s,error: "Failed to fetch price"}))}},})app.app({store: {price: null,lastUpdated: null,error: null},view: (ctx)=>(<divid="app"class="p-8">{ctx.store.error ? (<divclass="text-red-500">{ctx.store.error}</div>) : (<><divclass="text-4xl">${ctx.store.price??"..."}</div><divclass="text-gray-500 text-sm">
Updated: {ctx.store.lastUpdated??"never"}</div></>)}</div>),}).serve({port: 8080})Show who's typing with auto-expiry:
import{createHyperstar,hs,Schema}from"hyperstar"interfaceTypingUser{name: stringtimestamp: number}interfaceStore{messages: {id: string;name: string;text: string}[]typing: TypingUser[]}interfaceSignals{name: stringtext: string}constapp=createHyperstar<Store,{},Signals>()const{ name, text }=app.signalsconstsendMessage=app.action("send",{name: Schema.String,text: Schema.String},(ctx,args)=>{ctx.update((s)=>({
...s,messages: [...s.messages,{id: crypto.randomUUID(), ...args}],typing: s.typing.filter((t)=>t.name!==args.name),}))ctx.patchSignals({text: ""})})constsetTyping=app.action("typing",{name: Schema.String},(ctx,{ name })=>{ctx.update((s)=>({
...s,typing: [
...s.typing.filter((t)=>t.name!==name),{ name,timestamp: Date.now()},],}))})// Auto-clear stale typing indicatorsapp.repeat("clearTyping",{every: "1 second",handler: (ctx)=>{constnow=Date.now()ctx.update((s)=>({
...s,typing: s.typing.filter((t)=>now-t.timestamp<3000),}))},})app.app({store: {messages: [],typing: []},signals: {name: "",text: ""},view: (ctx)=>(<divid="app"class="p-4 max-w-md mx-auto"><divclass="space-y-2 mb-4">{ctx.store.messages.map((m)=>(<divid={m.id}><b>{m.name}:</b>{m.text}</div>))}</div>{ctx.store.typing.length>0&&(<divclass="text-gray-500 text-sm italic mb-2">{ctx.store.typing.map((t)=>t.name).join(", ")} typing...
</div>)}<form$={hs.form(sendMessage)}class="flex gap-2"><inputname="name"$={hs.bind(name)}placeholder="Name"class="border p-2 w-24"/><inputname="text"$={hs.bind(text)}placeholder="Message"class="border p-2 flex-1"hs-on:input={`Hyperstar.dispatch('typing', { name: $name.value })`}/><buttontype="submit"class="bg-blue-500 text-white px-4">Send</button></form></div>),}).serve({port: 8080})Animate values smoothly with easing:
import{createHyperstar,hs,Schema}from"hyperstar"interfaceStore{current: numbertarget: number}constapp=createHyperstar<Store>()constsetTarget=app.action("setTarget",{value: Schema.Number},(ctx,{ value })=>{ctx.update((s)=>({ ...s,target: value}))})app.repeat("animate",{every: 16,// ~60fpswhen: (s)=>Math.abs(s.current-s.target)>0.5,handler: (ctx)=>{ctx.update((s)=>({
...s,current: s.current+(s.target-s.current)*0.1,// Easing}))},})app.app({store: {current: 0,target: 0},view: (ctx)=>(<divid="app"class="p-8"><divclass="w-16 h-16 bg-blue-500 rounded-lg transition-none"style={`transform: translateX(${ctx.store.current}px)`}/><divclass="mt-8 space-x-2"><button$={hs.action(setTarget,{value: 0})}class="px-4 py-2 bg-gray-200 rounded">
Left
</button><button$={hs.action(setTarget,{value: 200})}class="px-4 py-2 bg-gray-200 rounded">
Right
</button></div><divclass="mt-4 text-gray-500">
Position: {Math.round(ctx.store.current)}px
</div></div>),}).serve({port: 8080})Clean up inactive sessions periodically:
import{createHyperstar}from"hyperstar"interfaceStore{activeSessions: number}interfaceUserStore{lastActivity: number}constapp=createHyperstar<Store,UserStore>()// Update activity timestamp on any actionconstping=app.action("ping",(ctx)=>{ctx.updateUserStore((u)=>({ ...u,lastActivity: Date.now()}))})// Clean up inactive sessions every minuteapp.cron("sessionCleanup",{every: "1 minute",forEachUser: (ctx)=>{constinactiveFor=Date.now()-ctx.getUserStore().lastActivityif(inactiveFor>5*60*1000){// 5 minutes inactiveconsole.log(`Session ${ctx.sessionId} inactive, cleaning up...`)// Perform cleanup logic here}},})app.app({store: {activeSessions: 0},userStore: {lastActivity: Date.now()},onConnect: (ctx)=>ctx.update((s)=>({ ...s,activeSessions: s.activeSessions+1})),onDisconnect: (ctx)=>ctx.update((s)=>({ ...s,activeSessions: s.activeSessions-1})),view: (ctx)=>(<divid="app"class="p-8"><div>Active sessions: {ctx.store.activeSessions}</div></div>),}).serve({port: 8080})Hyperstar is for building simple, real-time apps fast. It's not trying to replace Next.js or Rails.
Great for: Internal tools, prototypes, multiplayer games, live dashboards, chat apps.
Not built for: Multi-page SEO sites, offline-first apps, complex client-side interactions.
MIT

