Skip to content

JSX

@voltage/jsx is a custom JSX runtime that renders to HTML strings on the server. It is not React — there is no virtual DOM, no client-side hydration, and no React dependency. Components are plain NestJS services that implement a render() method, so the full DI container is available inside every component.

Terminal window
yarn add @voltage/jsx @voltage/event-manager

Add jsxImportSource to your tsconfig.json. Without this, TypeScript will not know how to transform JSX syntax:

{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@voltage/jsx"
}
}

Import JSXModule once in your root module. It registers the interceptor that automatically renders any JSX element returned from a controller:

import { JSXModule } from '@voltage/jsx';
@Module({
imports: [JSXModule],
})
export class AppModule {}

Create a component by extending InjectableComponent and decorating it with @Injectable():

import { Injectable } from '@nestjs/common';
import { InjectableComponent } from '@voltage/jsx';
interface HelloProps {
name: string;
}
@Injectable()
export class HelloView extends InjectableComponent<HelloProps> {
render(props: HelloProps) {
return <p>Hello, {props.name}.</p>;
}
}

Return JSX from a controller. The interceptor renders it to an HTML string before the response is sent:

@Controller()
export class AppController {
@Get()
index() {
return <HelloView name="world" />;
}
}

Components do not need to be registered as providers. JSXModule instantiates and caches them on first use.

InjectableComponent<Props> is the base class for components that need access to services. JSXModule instantiates each component on first render and caches it — no provider registration required.

render() can be async. Database queries, service calls, event emissions — anything you can do in a NestJS service works here:

interface AccountViewProps {
user: User;
}
@Injectable()
export class AccountView extends InjectableComponent<AccountViewProps> {
constructor(
private readonly intl: Intl,
private readonly query: QueryHelper,
) {
super();
}
async render(props: AccountViewProps) {
const orders = await this.query.for(Order).findAll(
where(belongsTo(props.user)),
);
return (
<main>
<h1>{this.intl.formatMessage(messages.title)}</h1>
<ul>
{orders.map(order => (
<li key={order.id}>{order.reference}</li>
))}
</ul>
</main>
);
}
}

For stateless markup that does not need DI, a plain function is enough:

interface BadgeProps {
label: string;
variant?: 'info' | 'warning' | 'error';
}
export function Badge(props: BadgeProps) {
const { label, variant = 'info' } = props;
return <span class={`badge badge--${variant}`}>{label}</span>;
}

Function components can be used anywhere in JSX just like injectable ones.

The class prop accepts a string, an array, or a nested array. Falsy values are ignored — the behaviour is the same as clsx:

<div class={['base', isActive && 'active', ['nested', condition && 'conditional']]} />
// → <div class="base active nested conditional"></div>

The style prop accepts a CSS properties object:

<div style={{ backgroundColor: '#fff', fontSize: '1rem' }} />
// → <div style="background-color: #fff; font-size: 1rem;"></div>

Children and attributes accept Promise and Observable values directly — no await or subscribe needed. The renderer resolves them automatically:

render() {
return (
<ul>
{items.map(item => (
<li>{this.service.label(item)}</li> // label() returns a Promise<string>
))}
</ul>
);
}

Observables work the same way — the renderer subscribes and uses the first emitted value:

render() {
return (
<div class={this.theme.current$}> // current$ is an Observable<string>
{this.content.load()} // load() returns an Observable<string>
</div>
);
}

The resolve() helper is useful when you need a resolved value inside an expression — for example when comparing against another value:

import { resolve } from '@voltage/jsx';
<a class={resolve(href).then(h => h === currentPath ? 'active' : undefined)}>
{label}
</a>

Fires after the entire tree has been rendered. event.output contains the final HTML string and is writable — the value you leave on it is what gets sent to the client. The typical use is injecting content into <head>:

import { OnEvent } from '@voltage/event-manager';
import { AfterRenderEvent } from '@voltage/jsx';
@OnEvent(AfterRenderEvent)
protected onAfterRender(event: AfterRenderEvent) {
const tag = `<style>${this.compiledCss}</style>`;
event.output = event.output.replace(/<head([^>]*)>/, `<head$1>${tag}`);
}

Fires before each element is rendered. event.name is the tag name or component class; event.attributes contains the resolved props. You can inspect attributes to react to them — for example, auto-loading a script when a specific attribute pattern is detected:

import { OnEvent } from '@voltage/event-manager';
import { BeforeElementRenderEvent } from '@voltage/jsx';
@OnEvent(BeforeElementRenderEvent)
protected onBeforeElement(event: BeforeElementRenderEvent) {
const hasAlpine = Object.keys(event.attributes).some(name => /^x-/.test(name));
if (hasAlpine) {
this.assets.include(AlpineAsset);
}
}

Set event.drop = true to remove the element from the output entirely.