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/core/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: a success (200) HTML response plus a JSON error response for 4xx | 5xx status codes using ErrorSchema.

Source -> packages/core/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/core/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/core/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/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.

Source -> packages/helpers/src/common/types.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