Choose your build pipeline

Build tools

Use the same Vite integration from a client-only SPA through a routed SSR app, or choose the matching Rspack or Rsbuild integration when those bundlers own your pipeline.

Octane has one compiler with thin bundler integrations. Choosing a bundler changes the build pipeline, not component semantics.

Choose an integration

GoalIntegration
Vite app (SPA or routed SSR)@octanejs/vite-plugin
Rspack SPA or custom server pipeline@octanejs/rspack-plugin
Full Rsbuild app@octanejs/rsbuild-plugin

All integrations use the same compiler, runtime selection, source maps, raw Octane dependency discovery, and cache dependencies. Choosing a bundler does not change component semantics.

Publishing a package built on Octane? Ship its authored modules as a source package so the consuming application compiles the library with the same Octane compiler and runtime as its own code.

All three integrations also accept profile: true for a client profiling build. See Profiling for Chrome tracks, render causes, the console API, and the recommended production-style workflow.

Vite

Use @octanejs/vite-plugin for every Vite app:

pnpm add octane @octanejs/vite-plugin
pnpm add -D vite
ts
// vite.config.ts
import { defineConfig } from 'vite';
import { octane } from '@octanejs/vite-plugin';

export default defineConfig({
	plugins: [octane()],
});

Without octane.config.ts, the plugin compiles Octane source and leaves Vite's normal client-only SPA behavior intact, including its import.meta.hot HMR dialect. Add an Octane config with routes to activate the full app layer: routing, streaming SSR, hydration, production client/server builds, and preview.

Raw-source Octane packages are discovered recursively. A binding whose dependency must stay outside Vite's rolling dependency optimizer can declare that package-owned constraint in its manifest:

json
{
	"octane": {
		"vite": {
			"optimizeDeps": {
				"exclude": ["identity-sensitive-core", "@identity-sensitive/*"]
			}
		}
	}
}

The Vite adapter adds exact entries to optimizeDeps.exclude automatically and expands a terminal family/* rule to matching packages declared by the binding or application. This metadata is for raw binding authors; applications consuming the binding do not need to repeat it in vite.config.ts.

Scoped <style> blocks do not become CSS assets. The compiler emits one injectStyle(hash, css) call per style scope inside the JavaScript module, so Vite's CSS pipeline — PostCSS, css.modules, build.cssMinify, and ?inline — never sees them, and there is no virtual CSS module to import or to list in manifest.json. Keep global stylesheets in ordinary .css files. See Styling.

Rspack

The Rspack package is deliberately low-level. It compiles .tsrx, eligible Octane .tsx, and raw Octane .ts/.js dependency sources, but leaves HTML, routing, and server orchestration to your application.

pnpm add octane
pnpm add -D @rspack/core @octanejs/rspack-plugin
js
// rspack.config.mjs
import { OctaneRspackPlugin } from '@octanejs/rspack-plugin';

export default {
	entry: './src/main.tsrx',
	plugins: [new OctaneRspackPlugin()],
};

The plugin infers client or server compilation from standard Rspack targets. Set it explicitly for an unusual target or a multi-compiler setup:

js
new OctaneRspackPlugin({ environment: 'server' });

Server compilation resolves exact bare octane imports to octane/server. Client compilation uses the DOM runtime and emits import.meta.webpackHot HMR handoff code when Rspack's hot loader context is active. Set hmr: false to disable component handoff, or transpile: false if another rule already strips TypeScript. The loader-only export at @octanejs/rspack-plugin/loader is available for custom rule composition.

Scoped CSS rides inside the compiled JavaScript: each style scope becomes an injectStyle(hash, css) call in the module, not a virtual CSS module, so css-loader, CssExtractRspackPlugin, and experiments.css do not apply to it and no extra rule is needed. Global stylesheets still go through your normal CSS rule. See Styling.

Rsbuild

Rsbuild is the closest equivalent to the full Vite metaframework integration. Its Environment API builds a browser hydration environment and a Node SSR environment with the correct Octane runtime in each.

pnpm add octane @octanejs/rsbuild-plugin
pnpm add -D @rsbuild/core
ts
// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginOctane } from '@octanejs/rsbuild-plugin';

export default defineConfig({
	plugins: [pluginOctane()],
});

Without octane.config.ts, pluginOctane() behaves as a compiler integration and preserves your own entries. With routes in octane.config.ts, it owns the web and Node entries required for SSR and hydration.

As with the other integrations, scoped <style> blocks ride injectStyle inside the compiled module rather than a virtual CSS module: Rsbuild's output.cssModules, tools.postcss, and CSS extraction leave them alone, and the SSR environment collects them per request into the render result's css. See Styling.

The shared build.target setting controls both SWC application transforms and Rspack's generated runtime. Rsbuild accepts one ES level, modules, false, or browser targets such as ['chrome100', 'firefox100', 'samsung24']; do not mix ES levels and browser targets in the same array. Samsung targets use the Samsung Internet version rather than its Chromium engine version: samsung24 means Samsung Internet 24, which is based on Chromium 117. The modules baseline includes Samsung Internet 14 and Chromium 87. Browser targeting transpiles syntax but does not supply application-specific Web API polyfills.

See Browser support for the recommended targets, required DOM APIs such as Element.replaceChildren(), feature-specific requirements, and optional fallbacks.

Strong mode

Strong mode opts into immutable render snapshots and asks the compiler to reject detectable state, ref, Effect Event, and purity violations. It is off by default, so you can try it in one file:

tsx
'use strong';

import { useLinkedState } from 'octane';

export function ProfileEditor({ user }) {
	const [name, setName] = useLinkedState(user.id, () => user.name);
	return <input value={name} onInput={(event) => setName(event.currentTarget.value)} />;
}

The directive applies only to its own module. Put it at the top of the file, before imports or other code; comments and other directives may come first. When a file needs an @jsxImportSource octane ownership pragma, put that comment first:

tsx
/** @jsxImportSource octane */
'use strong';

import { useState } from 'octane';

When the whole application is ready, switch it on in the shared app config used by the Vite and Rsbuild integrations:

ts
// octane.config.ts
export default {
	compiler: {
		strong: true,
	},
};

Each bundler plugin also accepts the same option directly. A standalone Rspack setup uses its plugin option because it does not load octane.config.ts:

ts
octane({ strong: true }); // Vite
new OctaneRspackPlugin({ strong: true }); // Rspack
pluginOctane({ strong: true }); // Rsbuild

An explicit plugin option wins over octane.config.ts. Application-wide mode does not change installed dependencies; a dependency can opt in with its own "use strong" directive.

A Strong module cannot:

  • Call a state setter or reducer dispatcher while rendering.
  • Call one synchronously while an effect is being set up.
  • Assign to a ref's current value while rendering.
  • Call a statically known useEffectEvent result during render (OCTANE_STRONG_RENDER_EFFECT_EVENT_CALL).
  • Include a statically known Effect Event in explicit hook dependencies (OCTANE_STRONG_EFFECT_EVENT_DEPENDENCY).
  • Mutate a provable state snapshot while rendering (OCTANE_STRONG_RENDER_SNAPSHOT_MUTATION).
  • Mutate a binding declared outside a retained keyed @for row from that row (OCTANE_STRONG_RETAINED_ROW_MUTATION). Fresh setup-local and row-local scratch data remain valid.
  • Read a known clock or random source directly while rendering, such as Date.now() or Math.random() (OCTANE_STRONG_RENDER_IMPURE_CALL). Lazy state initialization may still capture an initial timestamp or random value.
  • Declare a built-in hook value or its dependent effect outside the sole nested @{…} block that uses it (OCTANE_STRONG_HOOK_LOCALITY).
  • Declare a named native event handler outside the sole deeper nested @{…} block containing its direct onX use (OCTANE_STRONG_EVENT_HANDLER_LOCALITY).

A nested @{…} can place hooks and named event handlers beside the JSX that uses them:

tsx
"use strong";

import { useState } from 'octane';

export function Counter() @{
	<div>@{
		const [count, setCount] = useState(0);
		const onClick = () => setCount(count + 1);
		<button {onClick}>{count as string}</button>
	}</div>
}

An inline onClick is valid too. Hooks and effects used only by an @if, keyed @for, @switch, or @try arm may remain in their parent scope. Moving a hook into an arm or nested block changes its lifetime; each keyed row can have its own state. These locality checks apply to opted-in modules; strong: true in the app config enables them for all application-owned modules.

These checks follow provable synchronous calls through local helpers, useCallback and useEffectEvent results, and functions returned by analyzable useMemo factories. The hooks themselves remain supported. Effect Events are non-reactive; leave them out of dependency lists. Other explicit dependency arrays retain their existing meaning and are never rewritten.

Factories with unknown return values or complex control flow remain opaque. Dependency checks follow literal arrays, including statically selected or spread literals; they do not assume an aliased or externally produced array is unchanged.

"use strong" is also an author assertion that rendering is referentially transparent: the same witnessed inputs produce the same output, and render work has no application-visible side effects. Production client builds condition memoization on that assertion for every user-authored render operation, including local, imported, member, computed, and call-produced callees; calls with callback arguments; construction; and tagged templates. A name beginning with use does not change this contract or reintroduce Rules of Hooks. Actual hooks still belong in component or custom-hook setup, where the compiler assigns their slots and preserves context, state, suspension, and effect lifecycles. Recognition uses built-in import provenance, including optional calls, and lexically resolved same-module custom-hook declarations or function-valued module bindings, not a use* naming heuristic. Transitive and cyclic hook paths reach a fixed point. Memo guards witness a callable, its receiver, and explicit arguments; derived receivers are represented by their producing operation and inputs rather than a transient result identity. Guards at component and ordinary-list projection boundaries compare inputs with Object.is, distinguishing signed zero while stabilizing NaN; a certified keyed-selection operand retains authored strict equality.

The compiler checks the violations it can prove, but the analysis is deliberately bounded: an unknown call is assumed pure instead of disabling Strong memoization. Do not hide a ref read, state getter, mutable module or global, live external store, clock, random source, mutation, or other changing state behind a stable receiver or function. Keep live library accessors in compatibility mode, or pass an actual subscribed snapshot into a separate Strong component. Compatibility reevaluates a live accessor only when the scope containing it runs; it does not subscribe an unchanged child or make a stable live object snapshot-safe. See the memoization contract.

Use useLinkedState when state should reset or adjust after an input changes. Event handlers may update state normally. Genuinely deferred callbacks, effect cleanup, effects that connect to external systems, and refs for DOM nodes, timers, or event callbacks remain allowed when they run outside rendering.

Mixed toolchains and file ownership

By default the Octane compiler owns every project .tsrx and .tsx module. In a codebase where another framework's toolchain also compiles .tsx — for example React hosting Octane through OctaneCompat, Octane hosting React through ReactCompat, or another framework sharing the repository — that default would send the other framework's modules through the wrong compiler.

Set requireDirective: true (accepted by all three integrations) to split ownership explicitly. The rule is short:

  • .tsrx is always Octane's. The extension itself is the marker — nothing else compiles the syntax, so the files need no annotation at all.
  • Everything else opts in with the pragma. A .tsx, .ts, or .js is Octane's only if it opens with a leading /** @jsxImportSource octane */ pragma comment — full compilation for .tsx, octane hook slotting for .ts/.js; without the pragma it passes through untouched to the other framework's own pipeline.

In a .tsx the pragma is the same comment TypeScript already reads for per-file JSX typing, so one marker does both jobs: it types the file against octane's JSX and tells Octane's bundler integrations that this module is theirs to compile. In a JSX-less .ts/.js module TypeScript ignores the pragma, so there it acts purely as the Octane ownership marker. It must be leading — at the very top of the file, before any code. A pragma naming a registered renderer's intrinsics module (for example @octanejs/three/intrinsics) claims the file for Octane the same way. A pragma pointing at another framework (for example /** @jsxImportSource react */) does not claim the file: it stays with that framework's toolchain, exactly like a file with no pragma at all.

ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { octane } from '@octanejs/vite-plugin';

export default defineConfig({
	plugins: [octane({ requireDirective: true }), react()],
});
tsrx
// src/islands/Counter.tsrx — compiled by Octane (by extension, no marker)
import { useState } from 'octane';

export function Counter() @{
	const [count, setCount] = useState(0);
	<button onClick={() => setCount(count + 1)}>{'clicks:' + count}</button>
}
tsx
// src/App.tsx — no pragma: compiled by React's own pipeline
import { OctaneCompat } from 'octane/react';
import { Counter } from './islands/Counter.tsrx';

export function App() {
	return (
		<OctaneCompat>
			<Counter />
		</OctaneCompat>
	);
}

The same ownership split applies when an Octane component wraps a React component in ReactCompat from octane/react. React components keep React's JSX transform; React and React DOM remain real dependencies. The React compatibility guide covers both directions, including the commit boundary and SSR setup. In the Playground, choose OctaneCompat in React (multi-file) or ReactCompat in Octane (multi-file) for complete examples.

tsx
// src/islands/Badge.tsx — the pragma opts a .tsx into Octane
/** @jsxImportSource octane */
export function Badge(props: { label: string }) @{
	<span class="badge">{props.label}</span>
}
ts
// src/hooks/useCount.ts — the pragma opts a plain hooks module into Octane
/** @jsxImportSource octane */
import { useState } from 'octane';

export function useCount() {
	return useState(0);
}

The remaining details:

  • Custom octane hooks in a mixed project need compiler-assigned hook slots — an unslotted useState throws at runtime. Put them in a .tsrx module, or add the pragma to the .ts (or .tsx) module that defines them, exactly as above. (Installed octane packages are unaffected: their package manifest keeps making the per-package decision, hook slotting included.)
  • If a project .tsx/.ts/.js without the pragma imports from octane, the build emits a warning naming the file, since that is usually a forgotten pragma.
  • A different tsrx compiler (for example @tsrx/react) can own part of the project's .tsrx: list those paths in the integration's exclude option. Excluded paths are never Octane's — no compilation, no warnings. An octane pragma inside an excluded path is a conflict and gets a warning naming it (the exclusion wins).
  • Installed and linked packages are exempt: their package manifest's Octane declaration remains the per-package decision, so bindings need no pragmas.

The pragma is an ordinary comment, so it is valid — and ships unchanged — even when requireDirective is off; shared code and libraries can adopt it unconditionally.

Full app configuration

The config surface is shared by the Vite and Rsbuild integrations. Keep route module IDs project-root-relative and declarative so either bundler can turn them into stable client/server imports:

ts
// octane.config.ts
import { defineConfig, RenderRoute, ServerRoute } from '@octanejs/rsbuild-plugin';

export default defineConfig({
	router: {
		routes: [
			new RenderRoute({ path: '/', entry: '/src/Home.tsrx' }),
			new RenderRoute({
				path: '/posts/:id',
				entry: ['Post', '/src/Post.tsrx'],
				layout: '/src/Layout.tsrx',
			}),
			new ServerRoute({
				path: '/api/health',
				handler: () => Response.json({ ok: true }),
			}),
		],
	},
});

SSR templates require both markers:

html
<head>
	<!--ssr-head-->
</head>
<body>
	<div id="root"><!--ssr-body--></div>
</body>

The generated client entry hydrates the matched route, layout, pre-hydration hook, and root boundaries. The generated server entry statically imports route and module server owners, supports streaming or buffered SSR, and exposes the request-handler shape selected by the deployment adapter.

Production and preview

Use the normal Rsbuild commands:

bash
pnpm rsbuild dev
pnpm rsbuild build
pnpm octane-rsbuild-preview

The app build writes static browser assets to dist/client and the self-contained SSR entry and template metadata to dist/server. octane-rsbuild-preview starts that server entry. Change build.outDir in octane.config.ts to move both outputs together; configured deployment adapters run after both environments finish.

For Cloudflare Workers, install @octanejs/adapter-cloudflare and set adapter: cloudflare() in octane.config.ts. The build emits the module Worker at dist/server/worker.js. Keep wrangler.jsonc user-owned so bindings and routes remain explicit:

bash
pnpm add @octanejs/adapter-cloudflare
pnpm add -D wrangler
text
{
	"name": "my-octane-app",
	"main": "./dist/server/worker.js",
	"compatibility_date": "2026-07-14",
	"compatibility_flags": ["nodejs_compat"],
	"assets": {
		"directory": "./dist/client",
		"binding": "ASSETS",
	},
}

Leave static assets asset-first and keep assets.not_found_handling unset or set to "none": both the "single-page-application" and "404-page" modes can prevent navigation misses from reaching Octane SSR. Cloudflare bindings and the execution context are available to middleware and ServerRoute handlers as context.platform.{env,ctx}. Use wrangler dev for platform-local preview and wrangler deploy for deployment.

Rsbuild app mode currently expects root-path hosting: keep server.base at / and output.assetPrefix at auto or /. Deployments beneath a subpath should rewrite that prefix to the app root at the hosting proxy. Route/config edits within an already enabled app reload the browser. Changing build.target, build.outDir, build.minify, or adding the first route reshapes environments and needs a dev-server restart.

Renderer targets

Compiler and app-core options use declarative module IDs rather than bundler callbacks or instantiated renderer objects. That keeps DOM, Three, and Lynx renderer selection an Octane compiler/config concern.

@octanejs/rsbuild-plugin already follows the Rsbuild plugin model consumed by Rspeedy, but it targets Octane's DOM renderer. A Lynx application is a separate build: it runs Rspeedy with @octanejs/rspeedy-plugin, which compiles one authored entry into the main-thread and background graphs Lynx evaluates. See Native with Lynx. Do not point the web hydration entry at a Lynx application.