Build a custom adapter using the TestAdapter (packages/jsx/src/adapters/test-adapter.ts) as reference — a minimal working adapter that generates JSX output.
#Step 1: Implement `TemplateAdapter`
Extend BaseAdapter or implement TemplateAdapter directly:
import type {
ComponentIR,
IRNode,
IRElement,
IRText,
IRExpression,
IRConditional,
IRLoop,
IRComponent,
IRFragment,
ParamInfo,
} from '../types'
import { type AdapterOutput, BaseAdapter } from './interface'
export class TestAdapter extends BaseAdapter {
name = 'test'
extension = '.test.tsx'
private componentName: string = ''
generate(ir: ComponentIR): AdapterOutput {
this.componentName = ir.metadata.componentName
const imports = this.generateImports(ir)
const types = this.generateTypes(ir)
const component = this.generateComponent(ir)
const template = [imports, types, component].filter(Boolean).join('\n\n')
return {
template,
types: types || undefined,
extension: this.extension,
}
}
// ... node rendering methods (see below)
}#Step 2: Implement `renderNode()`
Route each IR node to the correct rendering method:
renderNode(node: IRNode): string {
switch (node.type) {
case 'element': return this.renderElement(node)
case 'text': return (node as IRText).value
case 'expression': return this.renderExpression(node)
case 'conditional': return this.renderConditional(node)
case 'loop': return this.renderLoop(node)
case 'component': return this.renderComponent(node)
case 'fragment': return this.renderChildren((node as IRFragment).children)
case 'slot': return '{children}'
default: return ''
}
}#Step 3: Implement Element Rendering
Render the tag, attributes, hydration markers, and children:
renderElement(element: IRElement): string {
const tag = element.tag
const attrs = this.renderAttributes(element)
const children = this.renderChildren(element.children)
let hydrationAttrs = ''
if (element.needsScope) {
hydrationAttrs += ' bf-s={__scopeId}'
}
if (element.slotId) {
hydrationAttrs += ` bf="${element.slotId}"`
}
if (children) {
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
} else {
return `<${tag}${attrs}${hydrationAttrs} />`
}
}#Attributes
private renderAttributes(element: IRElement): string {
const parts: string[] = []
for (const attr of element.attrs) {
const attrName = attr.name === 'class' ? 'className' : attr.name
if (attr.name === '...') {
parts.push(`{...${attr.value}}`)
} else if (attr.value === null) {
parts.push(attrName) // Boolean attribute
} else if (attr.dynamic) {
parts.push(`${attrName}={${attr.value}}`)
} else {
parts.push(`${attrName}="${attr.value}"`)
}
}
// Event handlers — render as no-op stubs for SSR
for (const event of element.events) {
const handlerName = `on${event.name.charAt(0).toUpperCase()}${event.name.slice(1)}`
parts.push(`${handlerName}={() => {}}`)
}
return parts.length > 0 ? ' ' + parts.join(' ') : ''
}The TestAdapter renders event handlers as no-op stubs for JSX. Non-JSX adapters omit them — handlers exist only in client JS.
#Step 4: Implement Expression Rendering
Reactive expressions with a slotId get a hydration marker for client JS updates:
renderExpression(expr: IRExpression): string {
if (expr.expr === 'null' || expr.expr === 'undefined') {
return 'null'
}
if (expr.reactive && expr.slotId) {
return `<span bf="${expr.slotId}">{${expr.expr}}</span>`
}
return `{${expr.expr}}`
}Non-JSX adapters convert expressions to the target language (e.g., count() → {{.Count}}).
#Step 5: Implement Conditional Rendering
Ternaries pass through in JSX adapters:
renderConditional(cond: IRConditional): string {
const whenTrue = this.renderNode(cond.whenTrue)
const whenFalse = this.renderNode(cond.whenFalse)
return `{${cond.condition} ? ${whenTrue} : ${whenFalse || 'null'}}`
}Input (JSX):
{isActive ? <span>Active</span> : <span>Inactive</span>}Output (TestAdapter):
{isActive ? <span>Active</span> : <span>Inactive</span>}Non-JSX adapters translate to the target conditional syntax (e.g., {{if .IsActive}}...{{else}}...{{end}}).
#Step 6: Implement Loop Rendering
.map() calls stay as JSX:
renderLoop(loop: IRLoop): string {
const indexParam = loop.index ? `, ${loop.index}` : ''
const children = this.renderChildren(loop.children)
return `{${loop.array}.map((${loop.param}${indexParam}) => ${children})}`
}Input (JSX):
{items.map(item => <li>{item.name}</li>)}Output (TestAdapter):
{items.map((item) => <li>{item.name}</li>)}Non-JSX adapters translate to the target iteration syntax (e.g., {{range .Items}}...{{end}}).
#Step 7: Implement Component Rendering
Pass the parent's scope ID to nested components:
renderComponent(comp: IRComponent): string {
const props = this.renderComponentProps(comp)
const children = this.renderChildren(comp.children)
const scopeAttr = ' __bfScope={__scopeId}'
if (children) {
return `<${comp.name}${props}${scopeAttr}>${children}</${comp.name}>`
} else {
return `<${comp.name}${props}${scopeAttr} />`
}
}#Step 8: Implement Hydration Markers
renderScopeMarker(instanceIdExpr: string): string {
return `bf-s={${instanceIdExpr}}`
}
renderSlotMarker(slotId: string): string {
return `bf="${slotId}"`
}
renderCondMarker(condId: string): string {
return `bf-c="${condId}"`
}#Step 9: Generate Signal Initializers
Signal getters return initial values during SSR via stub functions:
private generateSignalInitializers(ir: ComponentIR): string {
const lines: string[] = []
for (const signal of ir.metadata.signals) {
lines.push(` const ${signal.getter} = () => ${signal.initialValue}`)
lines.push(` const ${signal.setter} = () => {}`)
}
for (const memo of ir.metadata.memos) {
lines.push(` const ${memo.name} = ${memo.computation}`)
}
return lines.join('\n')
}const [count, setCount] = createSignal(initial) becomes:
const count = () => initial // getter returns initial value
const setCount = () => {} // setter is a no-op on the server#Optional: Type Generation
For typed backends, implement generateTypes():
generateTypes(ir: ComponentIR): string | null {
const lines: string[] = []
const propsTypeName = ir.metadata.propsType?.raw
if (propsTypeName) {
lines.push(`type ${this.componentName}PropsWithHydration = ${propsTypeName} & {`)
lines.push(' __instanceId?: string')
lines.push(' __bfScope?: string')
lines.push('}')
}
return lines.length > 0 ? lines.join('\n') : null
}For dynamically-typed backends, return null.
#Testing
import { compileJsxToIR } from '@barefootjs/jsx'
import { TestAdapter } from './test-adapter'
const source = `
"use client"
import { createSignal } from '@barefootjs/client'
export function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = createSignal(initial)
return (
<div>
<p>{count()}</p>
<button onClick={() => setCount(n => n + 1)}>+1</button>
</div>
)
}
`
const ir = compileJsxToIR(source)
const adapter = new TestAdapter()
const output = adapter.generate(ir)
console.log(output.template)
// export function Counter({ __instanceId, ... }) {
// const __scopeId = ...
// const count = () => 0
//
// return (
// <div bf-s={__scopeId}>
// <span bf="s1">Count: {bfText("s0")}{count()}{bfTextEnd()}</span>
// <button bf="s2" onClick={() => {}}>+1</button>
// </div>
// )
// }#Checklist
Ensure you handle:
- All IR node types (
element,text,expression,conditional,loop,component,fragment,slot) - Hydration markers (
bf-s,bf,bf-c) on interactive elements - Static vs. dynamic attributes
- Boolean HTML attributes (
disabled,checked, etc.) - Spread attributes (
{...props}) - Signal getter stubs for server-side initial values
- Nested component scope passing
- Props serialization (
bf-pattribute) for client hydration - Script registration for client JS loading
-
/* @client */directive (skip client-only expressions server-side)
Production adapters also handle:
- Void HTML elements (
<input>,<br>, etc.) — no closing tag - Expression translation to the target template language
- Type generation for typed backend languages
-
if-statementandproviderIR node types
#Reference Implementations
- TestAdapter (
packages/jsx/src/adapters/test-adapter.ts) — Minimal working adapter used throughout this guide - HonoAdapter (
packages/adapter-hono/src/adapter/hono-adapter.ts) — Production JSX-to-JSX adapter with script collection via Hono's request context - GoTemplateAdapter (
packages/adapter-go-template/src/adapter/go-template-adapter.ts) — Production adapter with expression translation, type generation, and array method mapping