On this page
GitHub ↗

Introduction

Radi is a lightweight reactive JSX runtime. Write components as plain functions and update the DOM directly.

Installation

Install Radi, then configure your bundler to use its automatic JSX runtime.

Add the package

Terminal
npm install radi

Configure JSX

Set the automatic JSX runtime in your TypeScript configuration.

tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "radi"
  }
}

Mount your first component

Add an element with id="app" to your HTML and load your bundled entry script.

main.tsx
import { createRoot } from 'radi';

function App() {
  return <h1>Hello, Radi.</h1>;
}

const root = createRoot(document.getElementById('app')!);
root.render(<App />);

Components

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.

greeting.tsx
function Greeting(props: () => { name: string }) {
  return <h1>Hello, {() => props().name}!</h1>;
}

Reactivity

Wrap dynamic children or attributes in functions. Call update on their ancestor to evaluate those functions again. Static content stays put.

counter.tsx
import { update } from 'radi';

function Counter() {
  let count = 0;
  const view = (
    <button onclick={() => { count++; update(view); }}>
      Count: {() => count}
    </button>
  );
  return view;
}

Lifecycle

Elements receive connect and disconnect events. Tie subscriptions to the component lifecycle with an abort signal.

Lifecycle
import { createAbortSignal } from 'radi';

function Panel(this: HTMLElement) {
  const signal = createAbortSignal(this);
  window.addEventListener('resize', () => {
    // Respond to the new viewport.
  }, { signal });
  return <div>Ready when you are.</div>;
}

Client routing

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';

const root = createRoot(document.getElementById('outlet')!);
const router = 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.

Routing guide, action examples, and Go/V8 integration requirements →

Server rendering

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';

const html = renderToString(h('h1', null, 'Hello from the server.'));
radi/server →

Go + embedded V8

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.

app.tsx
import { data, defineApp } from 'radi/go';

async function Title() {
  const row = await data<{ title: string }>('page', { id: 'home' });
  return <h1>{row.title}</h1>;
}

export const app = defineApp(() => (
  <main><p>Loading your page…</p><Title /></main>
));

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.

Build the Flop example
export CC=clang-21 CXX=clang++-21 CGO_CXXFLAGS=-nostdinc++
go generate ./examples/go-server
go build -trimpath -o radi-go-server ./examples/go-server
RADI_ADDR=127.0.0.1:8080 RADI_DATA_DIR=./data ./radi-go-server

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.

radi/go API · Complete Go setup, configuration, and deployment guide

Static sites

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.

build.ts
import { generateStaticSite } from 'radi/static';
import { createElement as h } from 'radi/server';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

await generateStaticSite([{
  path: '/',
  title: 'My site',
  render: () => h('h1', null, 'Made with Radi.'),
}], async (path, html) => {
  const file = join('dist', path);
  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, html);
});
radi/static →

Interactive islands

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.

Live example

A counter on a static page

0

Click to increment the counter.

Server boundary
import { createElement as h, resumable, resumeClick } from 'radi/server';

const counter = resumable({ count: 0 }, [
  h('button', { type: 'button', ...resumeClick('increment') }, 'Add one'),
  h('output', null, '0'),
]);
client.ts
import { resume } from 'radi/resume';
import { increment } from './handler';

resume(document, {
  increment: () => Promise.resolve(increment),
});
handler.ts
import type { ResumeContext } from 'radi/resume';

export function increment({ state, boundary }: ResumeContext) {
  const counter = 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.

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.

radi/resume →

Router API

Import from radi/router.

createRouter

Signature
const router = createRouter({ routes, initialData?, staleTime?, cacheSize? });
await router.ready;

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

Signature
router.state;
const unsubscribe = router.subscribe(state => { /* render */ });

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.

navigate

Signature
await router.navigate('/pages/about', { replace: false });

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.

attach

Signature
const detach = router.attach(document);

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.

submit

Signature
await router.submit('/pages/about', formData, 'POST');

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.

createFetcher

Signature
const fetcher = router.createFetcher<Result>();
fetcher.subscribe(({ data, error, status }) => { /* update a component */ });
await fetcher.submit('/posts/42/reactions', formData, 'POST', { invalidate: ['/posts/42'] });
await fetcher.load('/posts/42/reactions');
fetcher.dispose();

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

Signature
await router.revalidate();
router.invalidate();

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.

prefetch

Signature
await router.prefetch('/pages/about');

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.

dispose

Signature
router.dispose();

Aborts navigation, fetchers and prefetch, removes installed listeners, and clears data. Dispose before replacing the router.

fetchRouteData

Signature
const data = await fetchRouteData<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.

redirect

Signature
redirect('/pages/home');
throw new RouteRedirect('/pages/home');

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.

RouteError

Signature
throw new RouteError(404, 'Page not found');

Carries status and data into the router error state. Applications own error rendering, retry UI, focus management, and custom scroll restoration.

Go bridge API

Import from radi/go (embedded server bundle).

defineApp

Example
export const app = defineApp(
  (request: GoRequest): ServerChild => <main>Hello</main>,
);

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.

data

Signature
data<T = unknown>(name: string, input: unknown = null): Promise<T>

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.

GoRequest

Signature
interface GoRequest {
  method: string;
  url: string;
  headers: Record<string, string[]>;
}

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.

Client API

Import from radi or radi/client.

createRoot

Signature
const root = createRoot(target: HTMLElement);
root.render(element);
root.root; // current node, or null
root.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.

createElement

Example
import { createElement as h, createRoot, Fragment } from 'radi';

const target = document.body.appendChild(document.createElement('div'));
const view = 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.

update

Signature
update(target: Node): boolean

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.

memo

Signature
memo(render: (anchor: Node) => unknown, shouldMemo: () => boolean)

Returns a reactive function. The first evaluation computes its value; later updates retain it when shouldMemo returns true. Return false when dependencies change.

createList

Signature
createList(key => items.map(item =>
  key(() => <Row item={item} />, item.id)
))

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.

createKey

Signature
createKey(render: () => unknown, keyValue: unknown)

Returns a single keyed descriptor. Inside a reactive child, the same key preserves the rendered instance; a changed key remounts it.

createAbortSignal

Signature
createAbortSignal(target: Node): AbortSignal

Aborts on the target’s disconnect event. Use it to tie event listeners or async work to the component lifetime.

createAbortSignalOnUpdate

Signature
createAbortSignalOnUpdate(target: Node): AbortSignal

Aborts on the next update or disconnect. Create a new signal for each new unit of work.

Suspense

Signature
<Suspense fallback={() => <p>Loading…</p>}>
  <AsyncComponent />
</Suspense>

Displays its fallback while descendants are suspended. Overlapping suspensions are counted. Async components participate automatically; manual work must balance suspend and unsuspend.

suspend

Signature
suspend(target: Node): boolean
unsuspend(target: Node): boolean

Dispatch bubbling, cancelable suspend/unsuspend events. Return the dispatch result. Call unsuspend when each manual operation finishes, including failure paths.

ComponentNode

Signature
function Component(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.

Server API

Import from radi/server.

createElement

Signature
createElement(type, props, ...children): ServerNode
createTextNode(text: string): ServerChild
createComment(text: string): ServerNode
Fragment(children: ServerChild[]): ServerNode

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.

renderToString

Signature
renderToString(child: ServerChild): string
renderToStringRoot(child: ServerChild): string
ssr(entry: () => ServerChild): string

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.

renderToStringAsync

Signature
renderToStringAsync(child: ServerChild): Promise<string>

Collects fully resolved async output. Components, promises, and siblings are evaluated in document order.

renderToReadableStream

Signature
renderToReadableStream(child: ServerChild): ReadableStream<Uint8Array>

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.

ServerChild

Signature
type ServerComponent<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.

resumable

Signature
resumable(state: ResumeState, children: ServerChild, tag = 'div'): ServerNode
resumeClick(handler: string): { 'data-radi-click': string }

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.

Resumption API

Import from radi/resume (client) and radi/server (boundaries).

resume

Signature
const dispose = resume(root: Document | Element, loaders: ResumeLoaders, options?: ResumeOptions);
dispose();

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.

ResumeLoaders

Signature
const loaders = {
  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.

ResumeHandler

Signature
type ResumeHandler = (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.

ResumeOptions

Signature
resume(document, loaders, {
  maxPending: 64,
  timeout: 30000,
  onError: error => console.error(error),
});

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.

ResumeState

Signature
type ResumeState = null | boolean | number | string
  | ResumeState[] | { [key: string]: ResumeState };

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.

resumeClick

Signature
resumeClick('increment')
resumable({ count: 0 }, children, 'section')

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.

Static generation API

Import from radi/static.

generateStaticSite

Signature
generateStaticSite(pages: readonly StaticPage[], write: StaticWriter): Promise<string[]>

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.

StaticPage

Signature
const page: StaticPage = {
  path: '/guide/',
  title: 'Guide',
  render: () => h('h1', null, 'Guide'),
  lang: 'en',
  head: h('link', { rel: 'stylesheet', href: '/style.css' }),
  bootstrap: '/client.js',
};

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.

StaticWriter

Signature
type StaticWriter = (path: string, html: string) => void | Promise<void>;

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.

Signals and channels

Import from radi/signal and radi/channel respectively.

createSignal

Signature
const count = createSignal(0);
count(); // read
count(1); // write
const label = count(value => `Count: ${value}`);

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. The current overload types do not fully express the mapper’s returned function.

createChannel

Signature
const theme = createChannel('light');
const provider = theme.provide(element, 'dark');
const value = theme.use(descendant);
value();
provider.set(previous => previous === 'dark' ? 'light' : 'dark');

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.

ChannelAccessor

Signature
value();
value.set(nextValueOrUpdater);
value.update();
value.provider;
value.resolved;

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.

Custom renderer API

Import from radi/renderer.

createRenderer

Signature
const renderer = createRenderer(adapter: RendererAdapter);
renderer.createElement(type, props, ...children);
renderer.createTextNode(text);
renderer.createComment(text);
renderer.fragment(children);
renderer.render(root, child);
renderer.renderToString?.(child);

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.

adapters

Signature
createDomAdapter(document?)
createServerStringAdapter()
DOM_RENDERER
SERVER_RENDERER

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.

RendererAdapter

Signature
interface RendererAdapter {
  createElement(tag); createTextNode(text); createComment(text);
  setProperty(node, name, value);
  insertNode(parent, node, anchor?); removeNode(parent, node);
  getParentNode(node); getFirstChild(node); getNextSibling(node);
  isTextNode(node); isCommentNode?(node); serialize?(root);
}

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.

JSX and shared types

Import from radi/jsx-runtime, radi/jsx-dev-runtime, radi/server/jsx-runtime, radi/server/jsx-dev-runtime, radi/types, radi/symbols.

JSX

Example
import { createRoot } from 'radi';
import { jsx, jsxs, Fragment } from 'radi/jsx-runtime';
import { jsxDEV } from 'radi/jsx-dev-runtime';

const target = document.body.appendChild(document.createElement('div'));
const view = jsxs('main', {
  children: [
    jsx('h1', { children: 'Hello, Radi' }),
    jsxs(Fragment, {
      children: [
        jsx('p', { children: 'First sibling' }),
        jsx('p', { children: 'Second sibling' }),
      ],
    }),
    jsxDEV('p', { children: 'Development output' },
      undefined, false, { fileName: 'main.ts', lineNumber: 16 }),
  ],
});

createRoot(target).render(view);

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.

shared-types

Signature
isSubscribable(value): value is Subscribable<unknown>
isReactiveGenerator(value): value is ReactiveGenerator
toChildArray(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.

symbols

Signature
COMPONENT, REACTIVE_CHILDREN, TAIL, RENDER_ID, RENDER, MEMO,
REACTIVE_ATTRIBUTES, TYPE, PROPS, INSTANCE, UPDATE_ID, CLEANUP,
SINGLE_KEYED, KEY, NODE, KEY_MAP, ATTRS, KEYED

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.