Skip to content

JSX/HTML Utility - Full Reference

Exhaustive reference for htmlContent(), htmlResponse(), and BaseRestController.defineJSXRoute(). For a readable introduction and the common tasks, start with the JSX/HTML overview.

Files:

htmlContent()

Creates a standard OpenAPI content object for text/html responses.

Source -> packages/kernel/src/utilities/jsx.utility.ts

typescript
const htmlContent = (opts: { description: string; required?: boolean }) => ({
  description: opts.description,
  content: {
    'text/html': {
      schema: z.string().openapi({
        description: 'HTML content',
        example: '<!DOCTYPE html><html><head><title>Page</title></head><body>...</body></html>',
      }),
    },
  },
  required: opts.required ?? false,
});

Parameters

ParameterTypeRequiredDefaultDescription
descriptionstringYes-Description of the HTML content, shown in the generated OpenAPI document
requiredbooleanNofalseWhether the content is required

Returns

An OpenAPI content configuration object: description, content['text/html'].schema (a z.string()), and required.

htmlResponse()

Creates a standard OpenAPI response object for HTML endpoints. It pairs a success (200) HTML response with a JSON error response for 4xx | 5xx status codes using ErrorSchema.

Source -> packages/kernel/src/utilities/jsx.utility.ts

typescript
const htmlResponse = (opts: { description: string; required?: boolean }) => ({
  [HTTP.ResultCodes.RS_2.Ok]: htmlContent({ description: opts.description, required: opts.required }),
  ['4xx | 5xx']: {
    description: 'Error Response',
    content: { 'application/json': { schema: ErrorSchema } },
  },
});

Parameters

ParameterTypeRequiredDefaultDescription
descriptionstringYes-Description of the successful HTML response
requiredbooleanNofalseWhether the content is required

Returns

A responses object: 200 (via htmlContent()) plus 4xx | 5xx (JSON ErrorSchema).

typescript
import { htmlResponse } from '@venizia/ignis';

this.defineRoute({
  configs: {
    path: '/dashboard',
    method: 'get',
    responses: htmlResponse({ description: 'Dashboard HTML page' }),
  },
  handler: c => c.html(<h1>Dashboard</h1>),
});

BaseRestController.defineJSXRoute()

Defines and registers a JSX/HTML route in a single call - the JSX counterpart of defineRoute().

Source -> packages/kernel/src/base/controllers/rest/base.ts

typescript
defineJSXRoute<RouteConfig extends IAuthRouteConfig, ResponseType = unknown>(opts: {
  configs: RouteConfig;
  handler: TRouteHandler<ResponseType, RouteEnv>;
  hook?: Hook<any, RouteEnv, string, ValueOrPromise<any>>;
}): IDefineRouteOptions<RouteConfig, RouteEnv, RouteSchema, BasePath>
ParameterTypeRequiredDescription
configsRouteConfig (extends IAuthRouteConfig)Yespath, method, responses, plus the same authenticate/authorize/request/middleware fields defineRoute accepts
handlerTRouteHandler<ResponseType, RouteEnv>YesReceives the route context (TRouteContext); return c.html(<Component />)
hookHook<...>NoSame validation hook defineRoute accepts

Behavior

defineJSXRoute is defineRoute with one difference: it builds the route configuration through getJSXRouteConfigs instead of getRouteConfigs.

Source -> packages/kernel/src/base/controllers/rest/abstract.ts

typescript
getJSXRouteConfigs<RouteConfig extends IAuthRouteConfig>(opts: { configs: RouteConfig }) {
  const { restConfig, security, mws } = this.buildRouteMiddlewares(opts);
  const { responses, tags = [] } = restConfig;

  return createRoute<string, RouteConfig>(
    Object.assign({}, restConfig, {
      middleware: mws,
      responses: Object.assign({}, htmlResponse({ description: 'HTML page' }), responses),
      tags: [...tags, this.scope],
      security,
    }) as any,
  );
}
  • Default response merged in first. htmlResponse({ description: 'HTML page' }) is the base object. Your own responses is merged over it with Object.assign, so any status code you declare (typically 200) overrides the default entry with the same key.
  • Everything else matches defineRoute. Auth middleware, tags (this.scope is always appended), and OpenAPI security are built the same way as JSON routes via buildRouteMiddlewares.
  • Handler contract is unchanged. The handler still returns whatever c.html(...) produces (a Response). defineJSXRoute only changes how the route's OpenAPI shape is computed, not how the handler runs.

JSX setup

tsconfig.json

.tsx files compile against Hono's JSX runtime, not React's:

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  }
}

Verified in packages/core-server/tsconfig.json and the JSX example app's examples/rpc-api-server/tsconfig.json.

Component types

FC, PropsWithChildren, and Child are re-exported from @venizia/ignis-helpers (sourced from hono/jsx) - import them from there rather than reaching into hono/jsx directly. They live in common/jsx.ts, kept out of the ./common barrel on purpose: hono/jsx types reach DOM-dependent JSX intrinsics and hono is an optional peer, so a Worker or hono-less consumer of @venizia/ignis-helpers/common must not carry that file in its type graph. The root barrel (src/index.ts) re-exports the file directly, which is why the three names still resolve from the package root.

Source -> packages/helpers/src/common/jsx.ts

typescript
export type { Child, FC, PropsWithChildren } from 'hono/jsx';

No separate renderer

IGNIS registers no jsxRenderer middleware and no template engine. A handler builds a JSX tree and calls Hono's own c.html() on it - that call is what triggers rendering to an HTML string.

Component patterns

Layout composition

A layout component takes children (typed via PropsWithChildren) and wraps them in the surrounding document shell. Page components render into a layout the same way any JSX component nests another.

tsx
import type { FC, PropsWithChildren } from '@venizia/ignis-helpers';

interface MainLayoutProps {
  title: string;
  description?: string;
}

export const MainLayout: FC<PropsWithChildren<MainLayoutProps>> = ({ title, description, children }) => (
  <html lang="en">
    <head>
      <meta charSet="UTF-8" />
      <title>{title}</title>
      {description && <meta name="description" content={description} />}
    </head>
    <body>
      <main>{children}</main>
    </body>
  </html>
);

interface HomePageProps {
  timestamp?: string;
}

export const HomePage: FC<HomePageProps> = ({ timestamp }) => (
  <MainLayout title="Home" description="Welcome to IGNIS">
    <h1>Welcome to IGNIS!</h1>
    {timestamp && <p>Page rendered at: {timestamp}</p>}
  </MainLayout>
);

Wiring pages into a controller

tsx
import { BaseRestController, controller, htmlContent, type IControllerOptions, type ValueOrPromise } from '@venizia/ignis';
import { HTTP } from '@venizia/ignis-helpers';
import { HomePage } from '@/views/pages/home.page';

@controller({ path: '/' })
export class ViewController extends BaseRestController {
  constructor(opts: IControllerOptions) {
    super({ ...opts, scope: ViewController.name, path: '/' });
  }

  override binding(): ValueOrPromise<void> {
    this.defineJSXRoute({
      configs: {
        path: '/',
        method: 'get',
        description: 'Home page rendered with JSX',
        tags: ['Views'],
        responses: {
          [HTTP.ResultCodes.RS_2.Ok]: htmlContent({ description: 'Home page HTML' }),
        },
      },
      handler: c => {
        const timestamp = new Date().toISOString();
        return c.html(<HomePage timestamp={timestamp} />);
      },
    });
  }
}

Raw HTML with dangerouslySetInnerHTML

Hono JSX's intrinsic elements accept a dangerouslySetInnerHTML prop - an object with an __html string - matching React's escape hatch. Use it only for HTML you already trust (a stored template, sanitized markdown output) - never for unsanitized user input.

tsx
async previewTemplate(c: TRouteContext) {
  const { templateId } = c.req.valid<{ templateId: string }>('param');
  const template = await this.emailService.getTemplate(templateId);

  return c.html(
    <html>
      <head>
        <title>Email Preview: {template.subject}</title>
      </head>
      <body>
        <div dangerouslySetInnerHTML={{ __html: template.html }} />
      </body>
    </html>,
  );
}

Comparison with JSON utilities

htmlContent vs jsonContent

AspecthtmlContent()jsonContent()
Content typetext/htmlapplication/json
Schemaz.string()Caller-supplied Zod schema
Use caseHTML pages, JSX renderingAPI responses, structured data

htmlResponse vs jsonResponse

AspecthtmlResponse()jsonResponse()
Success typetext/html (200)application/json (200)
Error typeapplication/json (4xx | 5xx)application/json (4xx | 5xx)
Use caseServer-rendered web pagesREST APIs

See Schema Utility for jsonContent/jsonResponse.

Route definition choices

defineJSXRoute is the recommended way to register a JSX route because it fills in a default HTML 200 response automatically. Two equivalent alternatives exist when you need more control:

ApproachWhen to use
this.defineJSXRoute({ configs, handler })Default choice - HTML response shape is inferred, override responses[200] only if you need a custom description
this.defineRoute({ configs: { responses: htmlResponse({ description }) }, handler })You want the full responses object built explicitly via htmlResponse(), without relying on the JSX default merge
this.bindRoute({ configs }).to({ handler })Fluent two-step registration, same configs shape as either of the above

Authentication and authorization on a JSX route use the same configs.authenticate / configs.authorize fields as JSON routes - see Controllers.

See also