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.
Installation
Section titled “Installation”yarn add @voltage/jsx @voltage/event-managerTypeScript configuration
Section titled “TypeScript configuration”Add jsxImportSource to your tsconfig.json. Without this, TypeScript will not know how to transform JSX syntax:
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@voltage/jsx" }}Basic usage
Section titled “Basic usage”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.
Injectable components
Section titled “Injectable components”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> ); }}Function components
Section titled “Function components”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.
class and style props
Section titled “class and style props”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>Async children
Section titled “Async children”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>Render events
Section titled “Render events”AfterRenderEvent
Section titled “AfterRenderEvent”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}`);}BeforeElementRenderEvent
Section titled “BeforeElementRenderEvent”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.