◆ Fullstack Add-on — Frontend Layer · v2

A decorator-native frontend, bolted onto the Velocity backend.

Install @velocity/ui and the same core that serves your API serves Pages — Preact-scale class components, signal reactivity, and endpoints wired in without a single fetch(). Uninstall it and Velocity is byte-for-byte the backend it always was.

@Page components signals not state Callers · any method Chains · piped endpoints Context · Django-style SSR backend untouched when absent
Status: a design document — nothing built yet. Naming/architecture choices are (negotiable); see Open Decisions. Each feature ends with an assessment card (difficulty · cost · ship-odds · JS/Bun fit · market appeal). Jot your own thoughts in the Ideas panel — it saves to your browser.
00 · Customize

Make this page yours

Minimal, persistent (localStorage) reading preferences — theme, accent, font, and size.

Theme
Accent
Font
Size
01 · Philosophy & shape

Backend-first, frontend by installation

Velocity’s identity is a fast, batteries-included backend. The view layer ships as a separate package the developer chooses to install — the NestJS @nestjs/* model. Absent the package, there is nothing to gate: no frontend code on disk, in node_modules, or in RSS.

🧩 Core (always)

HTTP, router, DI, ORM, sessions, guards, clustering, shared memory, velogen. ~5K lines, 3 deps. Unchanged.

🎨 @velocity/ui (opt-in)

Adds @Page, the component runtime, signals, the renderer, Callers/Actions/Chains, Context providers, and the bundler.

🔗 The bridge

Controllers & services become typed clients/contexts inside components. One data model, authored once, consumed both sides.

browser GET /usersAccept: html? SSR view() → HTMLhydrate + signals
Caller → GET /api/userscontroller (JSON) same hot path as today
02 · The extensibility seam

Teaching the core about a layer it doesn’t contain

velo.scan() classifies against fixed symbols today. @Page is a new kind. Three bridges — starting with your inert-symbols idea.

A

Reserved inert symbols in core your idea · recommended base

Core pre-declares the frontend symbols as constants and scan() recognizes but tolerates them. Add-on absent → a @Page class is discovered, found to be a “page kind”, and skipped with a one-time hint. The decorators exist as no-op exports so user code type-checks either way; they only do something once the add-on registers a handler for that symbol.

core/src/kinds.ts

      
core — scan() gains one tolerant branch

      
Good
  • Zero runtime cost when absent (a map miss).
  • @Page type-checks even backend-only.
  • No plugin framework needed to start.
Costs
  • Core carries a few dead constants forever.
  • Kinds still enumerated in core.
B

Generic registerKind() plugin seam cleanest long-term

One generic hook: the add-on registers a kind (symbol + onDiscover that compiles the class into a route). Core knows “kinds”, not “pages” — and you get the framework’s missing general plugin API for free.

core — the entire new surface

      
@velocity/ui — plugs in, no core edit
C

Pure desugaring — @Page@Controller+@Get returning HTML smallest, leakiest

No new core concept: in the add-on, @Page('/x') expands to a hidden controller with a GET handler returning text/html. Rides the existing router. Catch: no place to special-case SSR streaming or html-vs-json negotiation.

@velocity/ui — desugar only
Recommendation: ship A now, evolve into B. Declare the inert symbols (your idea — importable, type-safe, pristine backend-only builds), have the add-on register via a minimal registerKind(). C is a fine internal detail for the actual route insertion.
03 · Component model

A Page is a class. A class is a component.

One @Page per file, extending Component. Signals and methods are fields; a few well-known methods are overridable — chiefly view(). Resolved by the same DI container that serves controllers.

src/pages/users.page.tsx — the shape

    
    
Server: one instance per request (SSR). Client: one instance per session (post-hydration). Constructors stay cheap; real work goes in onStart().
04 · Rendering

How view() returns markup

Four routes, from “free on Bun” to “build a compiler.” All target the same output: vnodes with signal-bound holes.

1

JSX via Bun’s native transform recommended primary

Bun compiles JSX for free. Point jsxImportSource at @velocity/ui; each tag becomes a jsx() vnode. Full typing, no custom toolchain.

tsconfig.json

      
a view
2

Tagged templates — html`…` build-free

lit/htm-style. Pure standard TS, no compiler. Parsed once per call-site, cached in a WeakMap — near-zero after first paint.

a view (.ts, no JSX)
3

h(tag, props, …kids) hyperscript escape hatch

The factory both others compile down to. Verbose but zero magic; great for programmatic trees.

4

A Velocity template compiler (Svelte-style) most power, most cost

Compile .vpage to direct DOM ops (no vnode diff). Fastest runtime — and a whole toolchain to own. (Related but lighter: template files, §11.)

ApproachBuildTypingRuntimePayloadDXMaint.
1 · JSX (Bun)freeexcellentgood¹smallhighlow
2 · html``noneokgood¹+2KBgoodmed
3 · h()nonegoodgood¹tinylowlow
4 · compilerhugeexcellentbestsmallestgoodvery high
¹ assumes fine-grained signal binding (below), not periodic re-render.

The rendering model matters more than the syntax

A · Re-render + diff React-like

Run view() on every change, diff, patch. Simple — but a fetching read inside view() re-fires each render (the crash you flagged).

B · Fine-grained binding recommended

view() runs once to build the tree; each signal read becomes a live binding that patches only its node. No periodic re-render; a Caller read subscribes once.

Fragments — no forced single root your request

Yes, and cheap. A view() may return a fragment (<>…</>) or an array of siblings. Internally a fragment is just a vnode with no host element — its children mount into the parent directly; when a component is used elsewhere, it expands to exactly those siblings (as you pictured). Fine-grained binding makes this free: there’s no wrapper node and no diff cost.

multi-root view

    
The only real complexity is keyed reconciliation across multiple roots (which sibling moved?), solved by the same key mechanism lists already need. Benchmark impact: negligible — a fragment is one fewer DOM node, not more. Verdict: ship it. (React forbade it originally only because its diff assumed a single root; a signals renderer has no such constraint.)
Decision (negotiable): fine-grained model · JSX primary + html` alt · fragments allowed.
05 · Reactivity

Signals, not state

A signal is a reactive box: read .value → subscribe; write → subscribers update. computed derives, effect reacts, batch groups.

Adopt @preact/signals-core recommended

A framework-agnostic ~1KB package with exactly signal/computed/effect/batch and a proven, glitch-free graph. No React dep. We re-export it under the Velocity namespace.

Build a minimal runtime fallback

~150 lines: observer stack, subscriber sets, topological invalidation, batch flush. Correct fine-grained reactivity is subtle (diamonds, cycles, cleanup) — the reason to prefer adopting.

@velocity/ui — the wrapper

    
usage

    
SSR caveat: signals must be per-request (never module singletons) or two users share state — the Component base scopes creation to the instance.
06 · Data

Linking components to endpoints — any method

The heart of the framework. Everything is typed off velogen, so URLs are never hand-written. Two primitives — Caller (you want the reactive result) and Action (you fire a command) — each now method-generic.

Callers — an endpoint bound to a Feeder

A Caller binds any endpoint to a Feeder (a signal holding the response). It doesn’t fetch eagerly: it fires when the Feeder is first read, or when a trigger changes. Method is generic — a POST that returns data you want to keep reacting to is a PostCaller. Caller('METHOD', …) is the base; the verb forms are shortcuts.

Caller family — GET/POST/PATCH/DELETE + base


    

Actions — an imperative command

An Action is a command you invoke on an event; it returns a promise and can invalidate Feeders or run optimistic. Same method family: Action('METHOD', …) base + Get/Post/Patch/DeleteAction shortcuts.

Action family


    
Caller vs Action — the one question: “Do I want to keep & react to the response?”Caller (reactive Feeder). “Do I fire it on an event and move on?”Action (imperative, invalidates). Both work with any HTTP verb; pick by intent, not method.

The three linking styles (choose per component)

StyleReactive?PerfMemoryDXBest for
Callers + Actions defaultfullcachecachedeclarativereads + writes, the common case
Controller injectionmanualgoodwhole clientfamiliarimperative flows / escape hatch
@GetApi memberssemigoodnarrowexplicitfew known calls (deferred)
controller-injection escape hatch (client = typed proxy)


    

Function-scoped Feeders your request

Besides the field/decorator form, a Feeder can be created inside a method via a functional API — it exists only within that reactive scope and is disposed when the scope ends. Useful for on-demand data (a modal, a one-off report) you don’t want alive for the page’s lifetime.

feed() / caller() — functional, scoped

    
The catch (why it’s optional): disposal. A Feeder made in a handler must be tied to a reactive root (Solid-style createRoot) or it leaks its effect. The runtime binds functional feeders to the component’s disposal scope by default and warns if one is created in a hot loop. Doable and clean — but it’s the one place lifetime bugs hide, so it ships behind the well-lit feed() API rather than as the default.

Calling @Fn endpoints as local methods your idea

Velocity already ships @Fn — RPC-style endpoints reachable at GET /.name(args). When a @Page shares a path/namespace with a controller, that controller’s @Fn methods can be surfaced as if they were methods of the component: write this.greet('Alice') and the add-on turns it into the RPC. The only obstacle is your exact observation — TypeScript won’t let you call a method that isn’t declared. Three ways around it:

1

Empty-bodied methods + @FnProxy works, but boilerplate

Declare the method with an empty/dummy body; a decorator overwrites it with the RPC. This is the literal version of what you described — functional, but every endpoint needs a throwaway body.

2

Interface declaration merging — no empty bodies recommended · the “bypass”

The clean answer to “can I avoid empty bodies?” — yes. Declare an interface with the same name as the class: TypeScript merges its method signatures onto the instance type, so this.greet(...) type-checks with nothing to implement. A class decorator (@FnsFrom) installs the real RPC proxies at runtime. This is the standard mixin trick — idiomatic, fully typed, zero dummy bodies.

3

Inject a typed Fns<Controller> client most explicit

No merging magic: inject an object typed from the controller’s @Fn surface and call through it. Slightly more verbose at call sites (this.fn.greet) but the most obvious to read.

Recommendation: Approach 2 (declaration merging) — it directly solves the “can’t call an undeclared function” problem with no empty bodies and full types. Approach 3 is the explicit fallback; Approach 1 only if merging feels too implicit. Bonus: if the controller is @Internal() at the same path (§08), these @Fn calls resolve server-side during SSR — combining this with endpoint-hiding so the function exists only inside the page.
Decision (negotiable): Callers + Actions blessed, method-generic; controller injection as escape hatch; @GetApi deferred; function-scoped feed() offered as an opt-in advanced API.
07 · Lifecycle & reactions

Overridable hooks and signal reactions

HookFiresUse
onStart()after mount / hydrationkick loads, subscriptions
onPause()idle for pauseAfter mspause polling, save draft
onResume() addedinteraction after pauseresume polling, revalidate
onClose()unmount / navigate awayflush, cancel in-flight
onError(e) addeduncaught error (boundary)fallback UI
lifecycle

    
onPause is the tricky one: needs a debounced activity monitor (pointer/key/scroll/visibility). Proposal: global default (60s), overridable via @Page('/x', { pauseAfter: 30_000 }); tab-hidden starts the timer; any input resets it; one shared monitor, not a timer per Page.

Reactions — “do X when signal Y changes” (beyond auto-DOM-update)

Imperative — watch(src, fn)
Declarative — @Watch('src')
Naming (negotiable): watch()/@Watch over “onChange/automater” — onChange collides with DOM handlers; watch is the term Vue/Solid devs already know. automate()/@Auto map to the same primitive if you prefer the identity.
08 · Routing & serving

Pages, endpoints, collisions & hiding

GET /usersAccept prefers htmlPage (SSR)
GET /api/userscontroller (JSON)

Default in fullstack mode: API under /api (Pages own the root) — no collisions, clean URLs. Callers target /api/* transparently.

prefix default

    

Endpoint hiding via @Internal() your idea, formalized

Mark an endpoint internal: never exposed over HTTP, only reachable from a component’s Caller (server-side during SSR, or via a signed page-scoped channel). Same path as a Page → the public GET returns the Page; the data stays private.

hiding

    

Backend ↔ fullstack sync

ConcernBackend-onlyFullstackMeasure
Symbolsinert constantsactivemeaningful only when a kind is registered
scan()skips pages (hint)routes to add-ontolerant branch behind “kind registered?”
Request entryno negotiationhtml-vs-jsonnegotiation installed only with add-on
Hot pathunchangedunchanged for APIPages are a separate compiled route class, never a runtime if
The rule that protects the benchmarks: a JSON API route in fullstack mode compiles to the exact same closure as today — only Page routes carry the render path.
09 · Chained calls

Piping endpoints — each response feeds the next

Your idea: call endpoints in order, each result flowing into the next, the last being the result — with destructors so only the needed slice passes forward (no giant input lists). Delivered as a first-class primitive with reactive and imperative variants.

Design

A Chain is an ordered list of steps. Each step has a method + path and a mapper that transforms the previous response into this step’s input — the mapper is the destructor: it picks only what’s needed. The final step’s response lands in a Feeder (reactive) or is returned (imperative). Triggers re-run the whole pipe; results are de-duped/cached by input tuple; an error short-circuits into .error.

1

@GetChain — all-GET shortcut the common case

When every step is a GET, a step is just “a path, derived from the previous response.” The arrow function is the destructor.

GetChain — reactive Feeder result
2

@Chain — mixed methods, explicit steps full power

Each step is an object: a verb+path, an input builder (body/params/query) and a pick destructor that narrows what flows onward. Keeps the pipe readable even at 4–5 steps.

Chain — the destructor is per-step `pick`
3

@ChainAction / fluent chain() — imperative on-event

Fire the pipe from a handler and await the final result — for wizard submits, multi-step mutations, etc. A fluent builder reads well for dynamic pipes.

imperative + fluent

Destructors — keeping inputs small

The worry you raised — “the input list gets very long” — is solved by making each step responsible only for its own input, derived from the immediately-previous result (not all prior results). If a step needs an earlier value, a tiny ctx accumulator carries just the fields explicitly kept via keep(). So the pipe never threads a growing tuple; each hop declares its slice.

keep() — carry only what later steps need

    
Decision (negotiable): ship Chain / GetChain (reactive) + ChainAction (imperative) as a dedicated primitive (not buried in Caller), with per-step pick/keep destructors and an all-GET shortcut. It composes with triggers & caching exactly like a single Caller.
10 · Context providers

Django-style SSR — the component reads a context, not endpoints

A distinct delivery mode you flagged for the future: the component never calls an API. A service exposes providers; when the Page’s path is opened, all its providers run server-side, merge into one context object, and the page is rendered from it and returned. Like Django templates / Remix loaders / getServerSideProps.

providers on a service + a context-fed page

    

How it runs: on GET /dashboard, the add-on resolves the declared service(s), runs every @Provider() in parallel (they’re server-only — full DB/DI access, no HTTP hop), merges the results into ctx, renders view(ctx) to HTML, and ships it. No client fetching for this data; it can be embedded in the hydration payload so the client never re-requests it.

Route params, dependencies & errors

Providers receive the request, so route params flow straight in. A provider can depend on another’s result (the add-on resolves the dependency graph and orders them), and a provider that throws surfaces a typed error you branch on in the view — so you never ship a half-rendered page. This is the whole ergonomic win over hand-wiring loaders: the page declares what it needs and the framework figures out how and in what order to get it.


    

Providers run in parallel by default; add after only when one genuinely depends on another. Each result can be cached — per-request always, and optionally across requests with a TTL — so an expensive query isn’t repeated for every visitor. Because it all runs server-side, a context page reads the session, hits the DB directly, and ships a fully-formed HTML document with zero client round-trips: the ideal shape for SEO-critical, content-heavy, or first-paint-sensitive routes. And it composes with Callers — take the context for the static shell, then let a @GetCaller drive the one live widget on the page. Context is the fast skeleton; Callers are the beating heart.

Context providersCallers/Feeders
Runsserver-side, on navigationclient-side, reactively
Data freshnesssnapshot per requestlive, re-fetches on triggers
Best forcontent pages, dashboards, SEOinteractive, changing data
Client JSminimal (static-ish)full runtime
They compose: a page can take a server context for its initial shell and use Callers for live widgets. Context is the fast first paint; Callers are the living parts. This section is intentionally brief — a placeholder to expand later; syntax above is a first sketch (negotiable).
11 · Template files

.tmpl files — extract the markup out of the class

For big components, move the view’s markup into a separate .tmpl file with the same syntax. Signals and child components are used identically inside — no re-declaration, no type ceremony. A preprocessor inlines it as the view body.

users.tmpl — same markup syntax, no imports/types needed inside

    
users.page.tsx — imports the compiled template

    

How it works: a Bun build plugin (or a velogen step) reads .tmpl, compiles the markup to a render function bound to the component instance (so this’s fields — query, users, loading — are in scope), and replaces view with it. Inside the file, identifiers resolve against the component instance, so no imports/types are repeated.

Complexities: (1) editor support — a .tmpl language mode for highlighting/JSX-in-file; (2) type-safety trade-off — relaxing types inside the file means typos there aren’t caught at compile time (mitigable with an optional @bind interface); (3) source-maps so errors point back to the .tmpl; (4) HMR must recompile the template on save. Worth it only for genuinely large views — for most, inline JSX is simpler.
Decision (negotiable): offer .tmpl as an opt-in build plugin, not the default. Inline JSX/html` stays the norm; templates are for the 500-line-view outliers.
12 · The rest of the architecture

Everything a real frontend also needs

🖥️ SSR + hydration

Render view() to a string server-side for first paint + SEO, then hydrate (attach bindings, don’t recreate DOM). Alts: CSR-only, SSG. Hard bit: hydration mismatch + embedding Feeder SSR values.

🧭 Navigation

Default MPA (full loads, fast on Bun); opt-in SPA router intercepts links, fetches next data via Callers, keeps signals alive. Hard bit: scroll restore, in-flight cancel, per-route splitting.

🧱 Layouts & nesting

@Layout wraps Pages via <Outlet/>. A layout is just a Component — reuses everything.

🎨 Styling

Scoped css` (hashed classes), object styles, plain CSS, or Tailwind. Hard bit: SSR-inline critical CSS to avoid FOUC.

🏷️ Head / SEO

head() / @Meta renders title/OG into <head> server-side. Hard bit: dynamic meta from a Feeder before the head flushes.

📝 Forms

Two-way <input model={sig}/>; submit via @Action. Hard bit: reuse the controller’s @Validate schema for instant client feedback.

🛡️ Security

Auto-escape interpolations (raw only via unsafe()); CSRF on Action POSTs; CSP for hydration scripts. Hard bit: audit every raw-output path (SSR-XSS).

⚡ Bundling & HMR

Bun.build → per-Page chunks; HMR swaps a Page while preserving signal values. Hard bit: keep the bundler an optional add-on dep.

🧵 Event-loop isolation

Heavy SSR blocks the loop → API p99 spikes (the one unavoidable enabled-mode cost). Mitigate: stream SSR, cap depth, offload to @Go workers.

Defaults (negotiable): SSR+hydrate · MPA+opt-in SPA · scoped css` · auto-escape+unsafe() · Feeder SSR values embedded · bundler optional dep.
13 · Suggestions

Borrowed from Vue & Angular — plus mine

A fast scan of both for ideas worth stealing. Each is a suggestion, not a commitment.

From Vue

VUESlots / content projection

Let a component accept markup from its caller — the clean way to build layouts, cards, modals.

VUETeleport — render outside the tree

Modals/toasts that must escape overflow:hidden render to a target node.

VUE<Transition> & KeepAlive

Declarative enter/leave animations; cache a Page instance across navigation so returning is instant.

From Angular

NGReactive forms + shared validation

Typed form models with validation — and reuse the same schema the controller validates with, for instant client feedback.

NGRoute resolvers

Pre-fetch a Page’s data before it activates (no loading flash) — essentially Context providers (§10), which is why I recommend that primitive.

NGClient HTTP interceptors + Pipes

Interceptors on Callers (auth token, retry, tracing) mirror Velocity’s backend interceptors. Pipes = pure display transforms in the view.

NGAngular adopted signals in 2026

Validation of our choice: the most enterprise framework moved to signals. We’re on the right side of history.

Mine

IDEAliveSignal — realtime cross-client state

A signal synced across all clients over the existing @WebSocket gateway, backed by SharedCounter/shm. Presence, live counters, collaborative UI — leveraging two features no competitor has.

IDEA@Island() — partial hydration

Islands architecture: the page is rendered to static HTML on the server and ships with no client JS by default. Only components marked @Island() get hydrated — each becomes an independent interactive “island” in a sea of static markup, loading its own tiny bundle (optionally lazily, when it scrolls into view). Everything else stays inert HTML forever.

Why it matters: a typical page is ~90% static (header, copy, layout) and ~10% interactive (a like button, a search box). Full hydration ships JS for all of it; islands ship JS for only the interactive 10% — dramatically smaller client payload and faster time-to-interactive. This is the win Astro popularized, and it fits Velocity’s “ship the minimum” ethos perfectly.


      

How it works: the add-on’s bundler emits one chunk per @Island component plus a ~1KB loader; SSR renders the whole tree to HTML and tags each island root with a data-attribute; the loader hydrates each island per its on option (load/idle/visible). Trade-off: islands don’t share live signal state directly — cross-island communication goes through a global store() or liveSignal (below). Backend impact: none — it’s purely a client-bundling strategy; the SSR output is the same HTML either way.

IDEA@Poll(ms)

Auto-refresh a Feeder on an interval — @Poll(5_000) re-runs the Caller every 5s and pauses automatically under onPause.

IDEAGlobal signal stores & <Await>

A store() is signals shared across pages and islands (cart, auth, theme) — the cross-island channel islands lack. Pair with an <Await feeder={…}> Suspense helper for declarative loading/error slots.

IDEA@Fn functions as client callables

Velocity already has @Fn RPC (GET /.name(args)). Surface those to components as typed functions — instant tRPC-like calls, no new backend concept.

IDEASignals devtools + Caller waterfall

A dev panel graphing the signal dependency tree and a network waterfall of Callers/Chains — the debugging story that makes implicit reactivity trustworthy.

IDEAView Transitions API for navigation

Wrap SPA navigation in document.startViewTransition for free animated page changes — modern, tiny, high-polish.

Ideas that need backend work first separate — core change required

These frontend features can’t exist until the core gains a capability. Each lists the backend change and its estimated effect on the backend’s benchmarks — because touching the core is the one thing that can move the numbers we’ve protected everywhere else.

BACKEND FIRSTLive queries — reactive DB → client push

Frontend: a @LiveCaller whose Feeder stays live — when the underlying rows change, the client updates with no polling.


      

Backend change: an ORM change-feed (emit on create/update/delete) + a WS subscription channel (Velocity already has @WebSocket + shared memory to fan out across workers). Benchmark effect: the GET hot path is untouched (reads don’t emit). Writes gain a few µs only when a subscriber exists (lazy emit), so POST/PUT throughput is unchanged in the common case; memory grows by bounded per-subscription bookkeeping. Verdict: opt-in per entity keeps the 42K GET / 29K POST intact.

BACKEND FIRSTStreaming SSR

Frontend: flush the page shell immediately, then stream each slot as its Feeder/provider resolves (Suspense-style), improving TTFB and perceived speed.

Backend change: the core must return a streamed Response (a ReadableStream body) rather than a fully-buffered one — Bun supports this natively, so the lift is moderate and localized to the Page route class. Benchmark effect: JSON API routes don’t stream and are unchanged; streamed pages hold slightly more per-request memory while in flight. Verdict: no API-throughput impact; a Page-path-only addition.

BACKEND FIRSTSparse fieldsets on Callers (select)

Frontend: @GetCaller('/api/users', { select: ['id','name'] }) → smaller payloads, less over-fetching.

Backend change: controllers/ORM must honor a field-projection param (partial SELECT + response shaping). Benchmark effect: adds a small per-request parse only when select is present (a few %); wire payload shrinks (net win end-to-end). Verdict: opt-in, measure — the query-builder change is the real work, not the frontend.

BACKEND FIRSTResumability (Qwik-style, skip hydration)

Frontend: instead of hydrating, serialize listeners + state into the HTML so the page is interactive on load with near-zero client JS and no replay.

Backend change: an SSR serializer that captures component handlers/state into the output. Benchmark effect: larger HTML payloads and higher per-render server cost (serialization) → SSR renders/sec drop somewhat; the JSON API path is untouched. Verdict: a big project with real upside for TTI — defer until the basics ship.

14 · Live sample

A CRUD fullstack app, whole-project

Two samples: Easy — a focused Users CRUD (switch data style with the tabs); Pro — a full app showing auth, encrypted sessions, entity relations, guards, validation, WebSocket + liveSignal, shared memory, clustering, context providers, chains, and @Fn. Flip the Sample switch. (Illustrative; not meant to compile.)

Sample ›
Data style ›
Shared files stay identical across variants; only users.page.tsx (and its imports) change with the selected style.
15 · Comparison

Both Velocity modes vs the field

No fullstack code exists yet → these are reasoned estimates from Velocity’s real backend numbers + published characteristics. Backend baseline (Bun, c=32): GET 42,927 · POST 29,750 · 66 MB.

Velocity backend-only vs Velocity fullstack

ParameterBackend-only (today)Fullstack · Tier-1 (JSX SSR)Fullstack · Tier-2 (hydration)
JSON API throughput42,927 GET~42K (±2%)~42K (±2%)
SSR renders/sec (simple)n/a~1.5–5K (est.)~1–4K (est.)
Idle RSS66 MB~68–72 MB~90–130 MB
Cold startbaseline~+5–15 ms~+150–400 ms
Client JS shipped0~5–15 KB~15–40 KB + page
node_modules63 MB~65 MB~150–200 MB
Prod deps33 + addon (0 new prod on Bun)3 + addon + bundler
New security surfacenone+SSR-XSS+hydration/build
API p99 under heavy SSR loadflatmild spikesspikes (shared loop)
The gate’s whole job: the first column is preserved exactly unless you opt in; Tier-1 barely moves it.

Fullstack capability matrix

ParameterVelocity+UINext.jsNuxtSvelteKitRemixElysiaHonoNest
SSRyesyesyesyesyesnoJSXplugin
Hydration / islandsT2RSCyesyesyesnoHonoXno
Streaming SSRplannedyesyesyesyesyes
Signals reactivityyesnorefrunesnoyes(new)
Built-in ORMyesnonononononono
Built-in DIyesnonononononoyes
Typed RPC to backendnativeactionsnitroformloaderEdenRPC
Piped/chained dataChainsmanualmanualmanualmanual
Realtime primitiveliveSignal*nonononowswsws
Cross-worker shmyesnonononononono
Edge/serverlessnoyesyesyesyesnoyesno
Ecosystemnewvastlargelargemedgrowgrowlarge
Learning curvemedsteepmedgentlemedgentlegentlesteep
* proposed (§13). “T2” = tier-2 planned. Bands are cross-hardware references, not same-box as Velocity’s numbers.

Estimated performance profile

MetricVelocity+UI (est.)Next.jsNuxtSvelteKitHono JSX
JSON API throughput~42K (unchanged)~10–15K¹~12–18K¹~18–25K¹~39K
SSR renders/sec (simple)~1.5–5K²53–104³381³589³~945³
Idle RSS (T1 / T2)68–72 / 90–130 MB120–200 MB150–250 MB90–130 MB55–70 MB
Client JS baseline~5–15 KB~87 KB³~70 KB~10–20 KB~0–5 KB
End-to-end type safetynative (velogen)partialpartialpartialRPC
¹ meta-frameworks pay routing/RSC/middleware on API routes too. ² near Marko/Kita class minus glue. ³ from ssr-benchmark + fullstack-feasibility.md.
Verdict: as a fullstack framework Velocity+UI would be mid-pack on raw SSR speed, top-tier on footprint-per-capability, and uniquely un-compromised on its API path — because Pages never touch the JSON hot path. It won’t out-render SvelteKit, and Elysia/Hono still win pure-backend single-process; fullstack doesn’t widen that gap. The pitch isn’t “faster SSR than Svelte” — it’s “the only Bun framework where one lightweight core does API + ORM + shared-memory clustering + a signals UI with piped data & realtime, and the backend numbers stay intact.”
16 · Your ideas

Jot & keep (saved in your browser)

Title + note → saved to localStorage. No server, no export — just for you, on this machine.

17 · Open decisions

Calls I made — flag any to change

#DecisionChosenAlt
★1Rendering modelFine-grained signal bindingVDOM re-render
★2Markup syntaxJSX (Bun) + html`compiler / .tmpl
★3Data linkingCallers + Actions (any method) + inject@GetApi (deferred)
★4SignalsAdopt @preact/signals-corebuild minimal
5Fragmentsallowed (multi-root)single-root
6Chainsdedicated primitive + per-step destructorsCaller sub-feature
7Context providersoffered as SSR modeCallers only
8Template filesopt-in build plugininline only
9Reaction APIwatch()/@Watch@Auto
10Package name@velocity/ui/front · /visuals
Tell me which ★ to revisit and I’ll re-derive the affected sections + the live sample. Biggest fork is ★1/★2.
Velocity — @velocity/ui fullstack add-on · design draft v2 · companion to fullstack-addon-vs-switch.md, fullstack-gated-mode.md, ../../researches/fullstack-feasibility.md.
Nothing here is implemented. A shared design surface — argue with it.