Open a terminal with Node.js 22+ and npm installed. Allow about 5 minutes for the SPA setup, depending on download speed.
Step 1: Create your app
Terminal
npmcreateradi@latest--my-app--templatespa
Use a new or empty directory. The generator refuses nonempty destinations and does not install dependencies.
Step 2: Install dependencies
Terminal
cdmy-appnpminstall
Step 3: Start the development server
Terminal
npmrundev
Step 4: Open your app
Open http://127.0.0.1:3000. Your Radi app is running. Edit app.tsx, save, and reload the browser to see your change.
Choose another template
Replace spa in the create command with the template you need:
spa — a browser-rendered app. Start here if you are unsure.
ssr — HTML rendered by a Node server for each request.
ssg — HTML generated at build time for a static host.
go — a Go server with embedded V8 for rendering.
The Go starter also requires Linux amd64 with glibc, Go 1.26.10, Clang 21, and CGO enabled. Follow its generated README.md to set the compiler environment before running npm run dev.
Each generated README.md explains editing, building, and deployment for that template.
Use an existing project
Already created a starter? Skip this manual setup. To add Radi to an existing project, follow the steps below. The reference describes the current repository API; check exports and types against your installed version before using newer features.
Add the package
Terminal
npminstallradi
Configure JSX
Use a bundler that supports JSX. Merge these options into your TypeScript configuration.
The types entry above loads Radi’s browser JSX declarations. Its JSX attributes are permissive, so type checking does not guarantee valid HTML or correct runtime behavior.
The signal, channel, renderer, types and symbols entries below describe source-checkout APIs. Check your package exports before importing those subpaths. The application recipes use the public client API.
Mount your first component
Add a <div id="app"></div> to your HTML, then mount a component from your browser entry.
A component runs once when it mounts. Keep state in its closure. Props arrive as a getter; call it inside a reactive expression to read the latest value.
The HTMLElement assertion narrows Radi’s broad JSX return type for a known HTML element. It does not convert the value. Components run once; destructuring props or returning count without a function captures only the initial value.
Elements receive connect and disconnect events. Tie subscriptions to the component lifecycle with an abort signal.
Lifecycle
import { createAbortSignal } from'radi';functionPanel(this:HTMLElement) {constsignal=createAbortSignal(this); window.addEventListener('resize', () => {// Respond to the new viewport. }, { signal });return <div>Ready when you are.</div>;}
Use stable IDs, never array positions or random keys. A retained key keeps its instance; its factory may not run again. Read replacement data through a getter instead of capturing the first item.
Type a draft, replace data, then reverse: labels change and drafts follow their IDs. Removing a row discards its local state. Use createKey for one stateful branch; change its key only when you want a reset.
Use native labels, validation and submit events. Read value or checked from the control, change state, then update the smallest view that displays it. Event handlers do not trigger updates automatically.
This example only validates locally. For saving, show pending/error/success states, prevent duplicate submissions, and validate again on the server. Catch failures where the operation starts; Suspense shows loading, not an application error boundary.
Keep state in the closest shared owner and pass getters and action callbacks to children. Use a channel when a DOM-scoped provider is more convenient.
Both buttons share one value and update together. Keep per-user server state inside the request. Signals are an optional low-level API: current direct signal children can miss their initial subscription during mounting, so use explicit owner updates for reliable shared state.
Cancel the previous request when its input changes. Also guard late results: cancellation alone does not stop every Promise. Cancel on disconnect, and show a retryable error.
Provide your own /api/search endpoint returning text. For live search at scale, debounce input and clear the timer on disconnect. Start DOM-dependent work on connect. Dispose subscriptions, timers, observers and router fetchers when their owner disconnects; remove trees through Radi so cleanup runs.
createAbortSignalOnUpdate cancels on every update, including one used to show loading. Use it only when that is the intended lifetime; use a separate controller for input-dependent requests as above.
update(view) synchronously visits reactive expressions in that subtree. Radi does not batch your calls or infer dependencies. A small runtime cannot make expensive application work free.
Update the smallest ancestor that contains every changed expression. Group related state changes, then call update once.
Keep render expressions pure and cheap. Fetch in an action or lifecycle handler; compute costly derived data when its inputs change.
Use keyed rows to preserve state, and paginate or virtualize large lists. Keys do not remove the cost of visiting every row.
Coalesce pointer/scroll updates with requestAnimationFrame; cancel the frame on disconnect. Debounce network searches separately.
Use memo only with a correct invalidation predicate: true skips work, false recomputes. Include every dependency or the view becomes stale.
Before shipping
Build against the installed Radi version. Try typing, reordering, removal, rapid input changes, failed requests and unmounting during a request. Profile a production build with realistic data and a slower device/network; check long tasks, interaction latency, transferred bytes and cleanup after repeated mounts. Measure before adding caches or memoization.
Use radi/router for SPA navigation without replacing the document. Keep shared layout outside a route outlet. Routes can load code lazily, read server data through loaders, and mutate it through actions followed by revalidation.
router.ts
import { createElement as h, createRoot } from'radi';import { createRouter, fetchRouteData } from'radi/router';constroot=createRoot(document.getElementById('outlet')!);constrouter=createRouter({ routes: [{ path: '/pages/:id',loader: ({ params, signal }) =>fetchRouteData('/api/pages/'+encodeURIComponent(params.id), { signal }, ), }],});router.subscribe((state) => {if (state.status !=='idle') return;if (state.error) root.render(h('p', null, 'Could not load this page.'));else root.render(h('pre', null, JSON.stringify(state.data, null, 2)));});router.attach();await router.ready;// Dispose when the application is removed: router.dispose();
Serve the application shell for direct visits to these URLs. The example expects a JSON endpoint implemented by your server. Authorization and persistence remain on the server; fresh data can still require a request.
The optional initialData setting reuses data serialized by your server. Rendering an outlet replaces its existing DOM; it does not hydrate it. Routes are flat, with static paths, :parameters, and a final * wildcard. This is not Remix API compatibility.
Caching is disabled by default. Enable staleTime and a bounded cacheSize to reuse prefetched data. Invalidate on account changes. New navigation aborts obsolete loads, but aborting an action cannot undo a server mutation.
Save through a route action
Add to the /pages/:id route
action: ({ params, formData, method, signal }) =>fetchRouteData('/api/pages/'+encodeURIComponent(params.id), { method, body: formData, signal },),
submit navigates and reloads the destination loader. Show submitting and error states from router.subscribe. Use a component-owned createFetcher for local changes that must preserve the route and its drafts; it runs only the action, so apply its returned data or explicitly reload. Dispose it on disconnect.
For code splitting, add lazy: () => import('./settings') to a route. That module exports loader, action and optionally view. Your subscriber calls root.render(state.module.view(state)) when idle; the router never renders it for you. Provide error/retry UI, focus management and any custom scroll restoration.
For Go integration, serve JSON endpoints beside the HTML handler and share authorized data functions between them. Safely serialize initial data into HTML and pass it as initialData. Authenticate, validate and enforce CSRF protection in the server endpoints; never expose the V8 operation registry as an API.
Use the separate server entry to render HTML without a browser. Choose synchronous rendering, resolved async HTML, or ordered streaming. For JSX on the server, set jsxImportSource to radi/server.
server.ts
import { createElement as h, renderToString } from'radi/server';consthtml=renderToString(h('h1', null, 'Hello from the server.'));
Keep application components in TypeScript/TSX while Go owns HTTP, authorization, and Flop access. The Go adapter embeds V8 in the server process and streams ordered HTML. It uses neither a Node/Deno serving process nor a persisted bytecode cache.
Register the page operation in Go and return its Flop result. data() resolves that result as a Promise. Bundle the TSX with radi-build, embed the JavaScript source with go:embed, and pass it to radi.New from github.com/Marcisbee/radi/go. The checked-in example includes the complete Go host and database setup.
Run these commands from a Radi checkout with Go 1.26.10 and Clang 21. Linux amd64 is the qualified target. The Go-based bundler needs no JavaScript runtime at build time. Each request gets fresh JavaScript state; the default single worker reuses its V8 instance and compiled source.
The adapter bounds queued work, data calls, response bytes, and execution time. Slow readers apply backpressure; disconnects cancel operations. Use trusted application code, external memory limits and a restart policy: the V8 heap setting is not a hard memory sandbox. Go operations must respect cancellation. Pair the adapter with radi/router for client navigation and route data handling through Go HTTP endpoints.
Use matching Radi release tags for Go and npm, or one source checkout for both. From the Go application module, bundle the app.tsx above with the following command. Replace VERSION with your pinned release tag; use -radi PATH to resolve a source checkout instead of the installed npm package.
Place this host beside app.js. The page operation returns public demo data; replace it with an authorized, validated database read. Add the matching github.com/Marcisbee/radi module version to go.mod.
Build with CGO enabled on Linux amd64/glibc using the Clang environment above, then run the binary. V8 has no fetch, timers, Node modules, DOM or Web Streams globals; use data() for I/O. Go operations may run concurrently and must bound their own work and honor context cancellation.
Limits and deployment
Config defaults
radi.Config{ Source: source, // required bundled source Workers: 1, Queue: 0, // excess requests receive 503 Timeout: 10* time.Second, // includes queue time WriteTimeout: 5* time.Second, // per response write MaxHeapBytes: 64<<20, // best effort, not an RSS cap MaxResponseBytes: 16<<20, MaxDataBytes: 1<<20, // each input/result and request metadata MaxDataCalls: 32, // per request OnError: func(r*http.Request, errerror) { log.Print(err) },}
Zero selects defaults except Queue, where it means no waiting capacity. Negative limits are rejected. One worker handles one response at a time, including slow readers; more workers cost memory. Data calls also have a global bound of Workers × MaxDataCalls.
The handler accepts GET only. Put mutations and authentication in separate HTTP handlers. Before streaming starts, failures return 500 or 504; after it starts, failure truncates the response. Component failures can render ERROR:ComponentName, so handle expected errors in the application. Keep diagnostics private with OnError.
Serve behind TLS, disable proxy buffering for early chunks, and preserve HTTP flush/write-deadline support in middleware. For shutdown, drain http.Server first, then Close the Radi handler with a deadline and close the database. Monitor errors, latency and process memory. Rebuild the bundle and binary together when upgrading; keep the previous binary for rollback.
This guide is built with Radi’s static site generator. Routes become complete HTML files. Your build owns the filesystem writer, asset bundling, and deployment; no Radi server runs in production.
Add explicit resumable boundaries to static HTML. Their handlers update existing DOM without rendering the whole page again. This example includes its handler in the initial script, so the first click needs no additional download.
importtype { ResumeContext } from'radi/resume';exportfunctionincrement({ state, boundary }:ResumeContext) {constcounter= state as { count:number }; boundary.querySelector('output')!.textContent =String(++counter.count);}
Bundle the client entry as a classic script and pass its public URL as the static page’s bootstrap. Dynamic imports are optional: use them for larger, rarely used interactions when the download delay is acceptable.
This is explicit resumption: arbitrary closures are not serialized. Use native links and forms for navigation. The current island API supports registered clicks.
Keep the initial markup and JSON state consistent. State changes persist in memory, not in the HTML attribute. Preserve the boundary’s final template marker: streamed clicks wait for it. Keep ordinary client roots outside islands, since their first render replaces existing HTML. Configure CSP to allow your bootstrap and any lazy handler modules.
Why are islands explicit?
Function children identify dynamic output, but do not identify a browser module or preserve a closure’s captured state. Radi currently has no compiler to extract those functions. A wrapper could generate registry entries for explicit module references, but could not automatically resume arbitrary components.
Creates one browser router per window. Routes specify path, loader, action, optional lazy module import, and optional view. Loader context contains url, decoded params, and signal. Action context also contains formData and method. initialData skips the first loader; staleTime enables data caching and cacheSize bounds entries (default 20).
State exposes url, match, module, data, actionData, error, and status (idle, loading, or submitting). subscribe immediately calls its listener. Existing data stays visible while busy. Render the outlet or an error when idle; the router never calls a module’s optional view itself.
Loads a same-origin route, then updates history. Back/forward resolves the browser URL. New navigation aborts obsolete work. Hash-only navigation skips the loader. Unmatched programmatic navigation produces a 404 RouteError.
Intercepts ordinary clicks on matching internal links. Modified clicks, downloads, external links, explicit targets, and data-radi-reload stay native. Forms are not automatically intercepted.
Runs the destination action, invalidates cached data, and reloads its loader. Supports POST (default), PUT, PATCH, and DELETE. Action results appear in actionData; a missing action produces 405.
Independent data state without navigation, scrolling, route notifications or automatic loader revalidation. load runs a fresh loader; submit stores action data in fetcher.state.data. Mutations invalidate all cache entries unless an explicit URL list is supplied ([] opts out). Errors and RouteRedirect stay in fetcher state. New work aborts older work on this fetcher; dispose it when its component unmounts. Cancellation cannot undo server writes.
revalidate clears cached data and reloads the current browser URL. invalidate() clears all cached data; invalidate([url, ...]) clears selected pathname/search keys without navigating. Use full invalidation after identity changes.
Loads route code and data without navigating. Data reuse requires a positive staleTime. Cache keys include pathname and search; hits do not extend expiry. Catch speculative failures. Prefetch does not follow redirects.
constdata=awaitfetchRouteData<Page>('/api/page', { signal });
Fetches JSON with Accept: application/json. Accepts JSON or 204; non-success responses throw RouteError with status and response data. A successful X-Radi-Location response redirects. The type parameter describes your contract, not runtime validation.
Throws a navigation redirect from a loader or action. Only same-origin destinations are supported and chains are limited to ten hops. Ordinary HTTP redirects followed by fetch are not route navigation.
Defines the application entry consumed by the Go adapter. Export it as app and bundle with radi-build, which selects server JSX and the Radi global. render runs once per request in a fresh V8 context. Components may return promises; content streams in document order. The returned start/pull/settle methods are the host protocol, not browser routing APIs.
Invokes an operation registered in the Go handler for this request. Input and output cross the bridge as JSON. The type parameter does not validate runtime data. Unknown operations, quota violations, invalid results, operation errors and panics reject the promise. Authorize and validate inside the Go operation; its context carries request cancellation and middleware values. This API is unavailable in the browser.
Request metadata supplied to defineApp. url is the request URI (path and query), not an absolute URL. Header names use Go HTTP canonical casing. The adapter accepts GET; mount separate Go handlers for mutations and other HTTP methods. No request body is exposed.
constroot=createRoot(target: HTMLElement);root.render(element);root.root; // current node, or nullroot.unmount();
Creates a DOM render root. The first render clears existing children; this is not hydration. Rendering the same component type and key reuses its host and updates props. Unmount disconnects the rendered tree and runs cleanup.
import { createElement as h, createRoot, Fragment } from'radi';consttarget= document.body.appendChild(document.createElement('div'));constview=h('main', { className: 'greeting' },h(Fragment, null,h('h1', null, 'Hello, Radi'),h('p', null, 'These siblings share no extra wrapper.'), ),);createRoot(target).render(view);
Creates DOM elements or component hosts. Components receive a props getter and run once on mount. Function children and attributes are reactive; Fragment groups children without an element wrapper. Use JSX for normal authoring.
Synchronously reevaluates reactive expressions on the target and its descendants. Returns the dispatch result. Update the ancestor containing the expressions that read your changed state.
Returns a reactive function. The first evaluation computes its value; later updates retain it when shouldMemo returns true. Return false when dependencies change.
Returns a keyed-list descriptor for use inside a reactive child function. Use stable, unique keys. Updates reuse keyed content and reconcile its order; removed items are disconnected.
Displays its fallback while descendants are suspended. Overlapping suspensions are counted. Async components participate automatically; manual work must balance suspend and unsuspend.
Dispatch bubbling, cancelable suspend/unsuspend events. Return the dispatch result. Call unsuspend when each manual operation finishes, including failure paths.
functionComponent(this:ComponentNode, props: () =>Props) {// this is the component host}
Type for the DOM component host. Lifecycle events are connect, disconnect, and update. Props are read by calling their getter, including inside reactive expressions.
Creates lazy server nodes, text, comments, and fragments. These are separate from client DOM and legacy adapter nodes. Components and function children execute during rendering. Text and attributes are escaped; no raw-HTML API is exposed.
Synchronous rendering; promises are rejected. renderToStringRoot is an alias. ssr calls the supplied entry and renders its result. Component errors produce an ERROR:ComponentName fallback; invalid children and reactive errors fail the render.
Streams ordered UTF-8 HTML with backpressure. Available HTML flushes before awaiting a promise; later siblings wait. Cancellation stops future traversal, but cannot cancel application promises already running. No out-of-order replacement scripts are emitted.
typeServerComponent<P> = (props: () =>P) =>ServerChild;// ServerNode: lazy server element representation// ServerChild: nodes, primitives, arrays, functions,// promises, and subscribable values
ServerChild, ServerComponent, and ServerNode are exported types. Subscribables supply their first synchronous value and are immediately unsubscribed. DOM lifecycle events do not run. Event, key, ref, and function attributes are omitted. Use explicit CSS units.
Creates an explicit island boundary and binds a click to a client registry key. See Resumption API for state, event, and delivery constraints. Legacy createRenderer, createDomAdapter, createServerStringAdapter, and SERVER_RENDERER are also re-exported; use them with adapter nodes only.
Install once per root before interactive HTML is parsed. Avoid overlapping roots. Returns an idempotent disposer that removes capture, aborts pending delivery, and releases cached loaders. It does not mount or rerender the page.
constloaders= {increment: () =>import('./handler').then(m=> m.increment),};// Or bundle the handler up front:// increment: () => Promise.resolve(increment)
A record of trusted handler keys to asynchronous loader functions. Loaded handlers are cached; a failed load can retry on a later click. Lazy imports are optional and can delay the first interaction.
typeResumeHandler= (context:ResumeContext) =>void|Promise<void>;// context: boundary, element, event, state, signal
boundary is the island element; element carries the click binding; event is the original MouseEvent (currentTarget is no longer valid). Read or replace state and mutate existing DOM. Honor signal for asynchronous work. Handlers run serially within a boundary. Removed targets are skipped.
Defaults are 64 queued/running clicks per root and a 30,000 ms wait for handler loading and streamed HTML readiness. Overflow is dropped and reported. The timeout does not forcibly stop application handler code.
Exported by radi/server and radi/resume. State is public HTML: never include secrets. Only finite numbers and JSON values in arrays/plain objects are accepted. Cycles, functions, undefined, BigInt, and class instances are rejected.
Handler keys must be nonempty and at most 256 characters. Supported boundary tags: div, section, article, main, aside, nav, span, li, td. Registered clicks suppress native defaults and propagation, then deliver once to the handler. Use type="button". Navigation, forms, input/change, gestures, and privileged user-activation actions are not replayed.
Validates all routes first, then renders and awaits each write sequentially. Returns generated relative paths. / maps to index.html; /guide/ maps to guide/index.html. Duplicate routes, dot segments, encoded paths, queries, and fragments are rejected. Unicode slugs are supported.
path, title, and render are required. render returns ServerChild, including promises. lang defaults to en; head and bootstrap are optional. bootstrap is a public URL for a bundled classic script, placed in the head before interactive content. Without it, no script is generated.
Owns filesystem writes, asset bundling/copying, cleanup, and deployment. Build into a fresh output directory: failures do not roll back earlier writes, and removed routes are not deleted. Deploy directory indexes; use the correct asset URL prefix for subdirectories.
Creates a callable signal. Writes notify connected subscriber nodes through update. A mapper call creates a reactive child function; it is not a functional setter. Subscribers attach through Radi connect/disconnect events, but direct signal children can miss the initial connect during mounting; prefer explicit owner updates for shared state. The current overload types do not fully express the mapper’s returned function.
Creates a DOM-scoped channel with a default value and unique key. provide attaches a value to an element; use resolves the nearest live provider after connection. Before resolution it reads the default, and writes are ignored.
Accessors read the current value. set changes it and updates the provider subtree; update forces a refresh after in-place mutation. provider is an Element or null, and resolved indicates provider discovery. Channel accessor types are inferred, not separately exported.
Legacy universal renderer for custom targets. render appends children to a UniversalNode root. renderToString exists only if the adapter supplies serialize. This is a one-shot abstraction, not full client reactivity or the lazy server streaming API.
Adapter factories target DOM or virtual string nodes. DOM_RENDERER and SERVER_RENDERER are prebuilt renderer instances. Keep creation and rendering within the same API; client DOM nodes, lazy server nodes, and UniversalNode wrappers are not interchangeable.
Implement node creation, mutation, navigation, and text detection. isCommentNode and serialize are optional. Exported types include RendererAdapter, Renderer, UniversalNode, and ComponentType. UniversalNode wraps a platform node with optional children, text, comment, tag, props, and key metadata.
Compiler-facing JSX factories. Set jsxImportSource to radi for DOM or radi/server for server nodes. Development entries re-export the runtime; jsxDEV accepts source metadata. The example shows calls normally generated by the JSX compiler: children live in props; jsx takes one child and jsxs takes multiple. The optional third argument is the key. jsxDEV also takes the static-children flag and optional source/self metadata. Server JSX exposes its own JSX namespace, including Element, ElementType, LibraryManagedAttributes, ElementChildrenAttribute, and IntrinsicElements.
isSubscribable(value): value is Subscribable<unknown>isReactiveGenerator(value): value is ReactiveGeneratortoChildArray(child): Child[]
radi/types exports Child, ReactiveGenerator, and Subscribable<T>. Subscribable.subscribe(listener) may return void, a cleanup function, or an unsubscribe object. The guards test for a subscribe method or a function respectively. toChildArray wraps a single child; it does not flatten nested arrays.
radi/symbols exposes runtime bookkeeping symbols for low-level integrations. Ordinary application code should use the public rendering and state APIs rather than mutate these fields.