Edit this page on GitHub

Server-Side MVC — Controllers, Views, and Rendering

Setu-TS serves server-rendered HTML as a capability, not as a framework-wide template system. A controller answers data; a view is a component the application already has — a function from a props bag to HTML — and an engine renders it to a string the framework sends with text/html; charset=utf-8.

The view is named by reference: @Render(UserList), never @Render('users/index'). There is no view resolver, no views directory and no filesystem lookup, which is what makes the capability portable to Cloudflare Workers by construction. It also makes a wrong view a compile error instead of a runtime 500 — the property the framework’s decorator surface is built on.

Registering the engine

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ViewPlugin } from '@setu-ts/view-plugin';

const app = createApplication({
  plugins: [RuntimePlugin(), ViewPlugin()],
});

await app.start({ port: 3000 });

Both arm names describe the authoring mode — which import your components use — rather than a rendering strategy; one engine serves both, and the selected mode is reported by the view health indicator. ViewPlugin() defaults to the 'hono-jsx' arm — components authored with hono/jsx, escaped by the JSX runtime, zero client JavaScript. ViewPlugin({ engine: 'hono-html' }) selects the html tagged-template arm, which needs no jsxImportSource and works in a plain .ts file. Declare hono as a direct dependency — npm install hono — because the application’s own compiler resolves hono/jsx/jsx-runtime and hono/html; the view plugin’s own hono dependency resolves only under a hoisting installer (npm, Bun), not pnpm or Deno; see the view-plugin README. The 'custom' arm registers an application-supplied IViewEngine verbatim.

The functional entry point

import type { IRequestContext } from '@setu-ts/common';
import { renderView } from '@setu-ts/view-plugin';

const UserList = (props: { readonly users: readonly string[] }) => (
  <ul>{props.users.map((user) => <li>{user}</li>)}</ul>
);

export function usersRoute(ctx: IRequestContext) {
  return renderView(ctx, UserList, { users: ['ada', 'grace'] });
}

The class-based entry point

import { Controller, Get, Render } from '@setu-ts/decorator-plugin';
import { ViewPlugin } from '@setu-ts/view-plugin';

// JSX escapes every interpolation. A plain
// `(props) => \`<li>${user}</li>\`` template would NOT — see Escaping below.
const UserList = (props: { readonly users: readonly string[] }) => (
  <ul>{props.users.map((user) => <li>{user}</li>)}</ul>
);

@Controller('/pages')
class PagesController {
  @Render(UserList)
  @Get('/users')
  users() {
    return { users: ['ada', 'grace'] }; // the props bag — the framework answers HTML
  }
}

ViewPlugin() is already registered by the application setup above — registering it a second time throws Duplicate plugin name 'view-plugin' at start().

Both entry points resolve the SAME engine under CAPABILITIES.VIEW and answer through the same IResponse.html(...) write. @Render type-checks the handler’s return against the component’s props; a route decorated with no provider registered fails at register() naming the controller, the handler and both remedies. A status code alongside a rendered body goes through @Params(Ctx()) — the return value IS the props bag, so @Render carries no status argument.

Forms

A form is the reason server-rendered HTML exists, and three things about it are not obvious.

Read forms through formData() — with its compatibility fallback. IRequest.formData?() parses both application/x-www-form-urlencoded and multipart/form-data into the framework’s read-only FormBody. All built-in request producers provide and memoize it. The accessor remains optional so custom IRequest implementations do not break; when it is absent, parse the replayable bytes with the same parseFormBody function and the request’s public content-type header. That preserves the same form semantics instead of silently treating a submitted form as empty.

Validate with the service, not the middleware. validateBody(schema) short-circuits with a Problem Details JSON body — correct for an API, useless for a form, which needs its own page back with the fields still filled in. IValidationService.validate() returns a Result instead of short-circuiting, so the handler decides what to render.

Re-render the same component, then redirect. The rejected submission renders the form it came from — one template, so the empty state and the error state cannot drift — and a successful one answers 303, so a refresh does not resubmit.

import { CAPABILITIES, parseFormBody } from '@setu-ts/common';
import type { IRequestContext, IValidationService, ValidationIssue } from '@setu-ts/common';
import { renderView } from '@setu-ts/view-plugin';
import { z } from 'zod';

const TaskSchema = z.object({
  title: z.string().trim().min(3, 'Title must be at least 3 characters'),
});

interface TaskFormProps {
  readonly values: { readonly title: string };
  readonly errors: Readonly<Record<string, string>>;
}

// JSX escapes every interpolation. This component redisplays a REJECTED
// submission, so `props.values.title` is attacker-controlled by definition.
const TaskForm = (props: TaskFormProps) => (
  <form method='post' action='/tasks'>
    <input name='title' value={props.values.title} />
    {String(props.errors.title ?? '')}
  </form>
);

/** Reads both form encodings, including custom requests without `formData()`. */
async function readForm(ctx: IRequestContext): Promise<Record<string, string>> {
  const form = ctx.request.formData === undefined
    ? parseFormBody(
      await ctx.request.bytes(),
      ctx.request.headers.get('content-type'),
    )
    : await ctx.request.formData();
  const out: Record<string, string> = {};
  for (const [key, value] of form.entries()) {
    if (typeof value === 'string') out[key] = value;
  }
  return out;
}

function firstMessagePerField(issues: readonly ValidationIssue[]): Record<string, string> {
  const out: Record<string, string> = {};
  for (const issue of issues) if (out[issue.path] === undefined) out[issue.path] = issue.message;
  return out;
}

export async function submit(ctx: IRequestContext) {
  const form = await readForm(ctx);
  const validation = ctx.services.get<IValidationService>(CAPABILITIES.VALIDATION);
  const result = validation.validate<{ title: string }>(TaskSchema, form);

  if (!result.success) {
    ctx.response.status(422); // unprocessable — and the page IS the error report
    return await renderView(ctx, TaskForm, {
      values: { title: form.title ?? '' },
      errors: firstMessagePerField(result.error),
    });
  }

  return ctx.response.redirect('/tasks', 303); // POST-redirect-GET
}

A rendered route may redirect

A @Render handler returns its props bag — but it may also return a HandlerResult from ctx.response, which is what makes POST-redirect-GET expressible on a decorated route. The redirect short-circuits before the view runs, so no HTML body is produced; the rejected submission instead re-renders the form with a 422. HandlerResult is branded, so widening the return union costs no type safety: a props bag of the wrong shape is still a compile error.

import { Controller, Ctx, Get, Params, Post, Render } from '@setu-ts/decorator-plugin';
import type { HandlerResult, IRequestContext } from '@setu-ts/common';

interface TaskFormProps {
  readonly values: { readonly title: string };
  readonly errors: Readonly<Record<string, string>>;
}

const TaskForm = (props: TaskFormProps) => (
  <form method='post' action='/tasks'>
    <input name='title' value={props.values.title} />
    {String(props.errors.title ?? '')}
  </form>
);

@Controller('/tasks')
class TasksController {
  @Render(TaskForm)
  @Get('/new')
  blank(): TaskFormProps {
    return { values: { title: '' }, errors: {} };
  }

  @Render(TaskForm)
  @Params(Ctx())
  @Post('/')
  async create(ctx: IRequestContext): Promise<TaskFormProps | HandlerResult> {
    const form = await ctx.request.formData?.();
    const field = form?.get('title');
    const title = typeof field === 'string' ? field.trim() : '';
    if (title.length >= 3) return ctx.response.redirect('/tasks', 303);

    ctx.response.status(422);
    return { values: { title }, errors: { title: 'Title must be at least 3 characters' } };
  }
}

export { TasksController };

Rendered output is buffered

IViewEngine.render answers string | Promise<string> — rendering buffers before send and never returns a stream. That is a decision, not an omission, and it is why a pending <Suspense> boundary is refused by name (UnresolvedSuspenseError) instead of served: buffered rendering of a <Suspense> tree emits only the fallback, with a 200 and no error — a loading placeholder served forever. Streaming resolution needs an injected client script that swaps the content in, which forfeits the zero-client-JS property the default arm is built on; it is deferred to a follow-up milestone rather than shipped as a silent default. An async component WITHOUT <Suspense> renders clean — only the boundary is refused.

Layouts are components taking children

A layout is an ordinary component that accepts children:

import type { Child } from 'hono/jsx';

function Layout(props: { readonly title: string; readonly children: Child }) {
  return (
    <html>
      <head>
        <title>{props.title}</title>
      </head>
      <body>{props.children}</body>
    </html>
  );
}

export { Layout };

There is deliberately no plugin-level layout option. Such an option would wrap EVERY render — including the fragments and partial responses IResponse.html already serves to HTMX-style callers, where a full document is the wrong answer — and a page wanting no layout would then need an opt-out, which is more surface than the composition it replaces. Compose explicitly instead: wrap the child component in the layout at the call site.

A by-name engine adapts; the port stays by reference

The port carries exactly one method. A string-named engine (Handlebars, Eta) participates through the 'custom' arm by adapting each compiled template to a (props) => string function — which is already a Component<P>:

import type { IViewEngine } from '@setu-ts/common';
import { ViewPlugin } from '@setu-ts/view-plugin';

const engine: IViewEngine = {
  render: (component, props) => String(component(props)), // component(props) → template output
};

app.register(ViewPlugin({ engine: 'custom', view: engine }));

Escaping

Interpolations are escaped by the rendering runtime in both default arms; the one opt-out is hono’s own raw(), re-exported from @setu-ts/view-plugin so an application does not import hono directly. No escaping logic lives in the plugin, so the two arms cannot disagree about it.

The escaping belongs to the runtime, not to the plugin — and a plain string component therefore gets none. Component<P> is structural, so (props) => string is a valid component: it is the shape a by-name engine adapts a compiled template to, and the plugin returns whatever it produced unchanged. That is correct for a Handlebars or Eta template, which has already escaped its own interpolations, and it is an XSS hole for a hand-written template literal:

import { html } from 'hono/html';

// UNSAFE: `name` is interpolated raw. With name = '<script>alert(1)</script>'
// this serves the script tag verbatim.
const Unsafe = (props: { readonly name: string }) => `<p>Hello, ${props.name}</p>`;

// Safe: the tag escapes the interpolation.
const Safe = (props: { readonly name: string }) => html`<p>Hello, ${props.name}</p>`;

Write views with JSX or the html tag. Reach for a plain string only when the value is already escaped by the engine that produced it.

Escaping protects HTML structure, not URL schemes

The one class escaping does not cover is the one that carries none of the characters the escape pass rewrites. A javascript: URL needs neither < nor & nor a quote, so it survives both arms — and the browser executes it when the link is clicked:

// The stored value, attacker-controlled — a note's user-editable link field:
const link = 'javascript:alert(1)';

// Rendered the documented way, in either arm — <a href={link}>open</a> — the
// attribute carries the scheme verbatim, because there is nothing to escape:
//
//   <a href="javascript:alert(1)">open</a>    clicking the link executes it.

Escaping neutralises <script>alert(1)</script> in the same position because that payload is made OF metacharacters; the scheme payload is not. The remedy therefore lives in the application, not the view: validate the scheme of a user-supplied URL before it reaches the view — an allowlist as small as http: and https: where the value enters the application — and treat every rendered URL attribute (href, src, action) as untrusted until then.

This is a difference BETWEEN rendering runtimes, not a property of JSX. React’s server renderer rewrites the same attribute to href="javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')" — it neutralises the value; hono’s runtime, which both arms here render through, passes it through. A reader arriving with a correct-for-React mental model is wrong here, and nothing in this framework will correct it for them.

This is checked, not just asserted. pnpm run check:docs runs scripts/check-example-behaviour.ts, which renders every component this repository documents — including the two above — through the framework’s own renderer twice: once with <script>alert(1)</script> substituted into its props, which escaping must neutralise, and once with javascript:alert(1), which must not survive into a rendered href, src or action. A component whose own source routes a value through raw() — or spreads {...props} into an element — is reported unchecked and fails the gate unless its own comment carries an UNCHECKED-EXEMPT label naming the reason: the gate does not claim a pass for markup it could not have delivered a payload into. A component a comment labels UNSAFE or DO NOT USE is checked in the other direction, so the warning above fails the gate if it ever stops being true.

An inline <script> must use raw()

Escaping applies to every interpolation, including the body of a <script> element. A client script interpolated plainly is emitted with &#39; for each quote and &lt; for each < — valid HTML, and a SyntaxError in the browser. The script never runs and nothing reports why, so any page that opens an EventSource or a WebSocket needs the opt-out:

import { html } from 'hono/html';
import { raw } from '@setu-ts/view-plugin';

const CLIENT = `const es = new EventSource('/events');
es.onmessage = (e) => { if (e.data < '9') console.log(e.data); };`;

// UNCHECKED-EXEMPT: raw() wraps the module-level CLIENT constant, not a prop —
// nothing user-controlled reaches raw(), so the probe's stub hides nothing.
export const Page = () =>
  html`
    <div id="log"></div>
    <script>${raw(CLIENT)}</script>
  `;

The JSX arm has the identical trap in its own spelling — <script>{CLIENT}</script> escapes the same way and needs the same raw(CLIENT).

Take the CSP this section implies deliberately, and separately from the URL hazard above. An inline <script> needs script-src 'unsafe-inline' — and 'unsafe-inline' is exactly the CSP that does NOT block a javascript: URL. An application that followed both pieces of advice casually would hold the door open for the one payload a CSP with 'unsafe-inline' cannot stop. Prefer an external script (or a nonce- or hash-based script-src) when the client script can move, and keep script-src 'self' — no 'unsafe-inline' — as the defence in depth: it blocks a javascript: navigation even when a value slips past validation.

Error pages

respond covers what errorHandler catches; every responder terminal is outside it. That is the whole rule, and @setu-ts/exceptions’ own contract pins it — what the rule leaves uncovered in a browser-facing application is the part worth spelling out. A mistyped URL (404), a logged-out user (401), an authorization service that is not configured (501), a stale form (403) and a throttled client (429) are all emitted by responder terminals, so each answers with the JSON of whatever formatter errorHandler was configured with — Problem Details under format: 'rfc9457', as below, and the { error, detail } default otherwise — no matter what respond renders for the errors your own handlers throw.

The worked example — the page IS the error report, and the callback owns its status:

import { errorHandler, statusTitle } from '@setu-ts/exceptions';
import type { HandlerResult, IRequestContext } from '@setu-ts/common';
import { renderView } from '@setu-ts/view-plugin';

interface ErrorPageProps {
  readonly status: number;
  readonly title: string;
}

const ErrorPage = (props: ErrorPageProps) => (
  <main>
    <h1>{props.status}</h1>
    <p>{props.title}</p>
  </main>
);

function wantsHtml(ctx: IRequestContext): boolean {
  // Deliberately simple — the point here is that the callback owns the status,
  // not content negotiation. This does NOT honour `q` values, so a client
  // sending `text/html;q=0` (a refusal) still gets HTML; parse the media
  // ranges if your callers send them.
  return (ctx.request.headers.get('accept') ?? '').includes('text/html');
}

app.middleware.add(
  errorHandler({
    format: 'rfc9457',
    async respond(error, ctx): Promise<HandlerResult | undefined> {
      if (!wantsHtml(ctx)) return undefined; // API clients keep Problem Details
      // The callback owns the result's status, and renderView takes none —
      // without this line every branded error answers 200.
      ctx.response.status(error.statusCode);
      return await renderView(ctx, ErrorPage, {
        status: error.statusCode,
        title: statusTitle(error.statusCode),
      });
    },
  }),
  { priority: 0, name: 'error-handler' },
);

The hook’s full contract — masking, logging, and the undefined fallback — is documented in @setu-ts/exceptions.

Health

The plugin registers a view health indicator reporting the selected engine. It is always up: rendering is stateless and touches no backend, and a probe must not fabricate reachability it cannot observe.

More

Styling server-rendered views

Run setu add view in a generated REST project. It adds hono, the hono JSX compiler options, Tailwind 4’s standalone CLI, neutral shadcn theme tokens in src/views/styles.css, and an example /ui page in src/views/home.tsx, served by a function controller like any other route: src/controllers/views.controller.ts, or src/modules/<module>/views.controller.ts with --module <module>, which a project with modules requires. The layout keeps the header and sidebar fixed while the content scrolls. It reads the stored light/dark/system preference before paint; its toggle follows the system preference while in system mode.

The command writes only absent pieces. Existing files, dependency pins, JSX choices and CSS scripts are preserved and named in its output. When setu.config.ts is not a recognized generated factory, it prints the plugin and route registrations to add manually. Review those notes before starting.

On Node, run npm install, then npm run build and npm start. Bun uses bun install, bun run build and bun start; Deno uses deno install, deno task build and deno task start. A build or start:dev script the command creates or prefixes runs css first, emitting build/assets/app.css; StaticPlugin serves it at /assets/app.css. A script that already called css is left as it was, so check that it runs css before the build. In another terminal, run npm run css:watch, bun run css:watch, or deno task css:watch while editing views. There is no combined watcher command. The generated production image already copies build/.

shadcn’s class recipes work with hono JSX: the generated home page uses card and button classes as plain strings. Interactive shadcn components do not work on this path because server-rendered views ship no React client JavaScript. Use the full-stack template for interactive React components; it includes Tailwind, shadcn Button/Card and components.json for npx shadcn add <component>.

A 'custom' React view engine can render static shadcn components. Every React file, including copied shadcn components, needs /** @jsxImportSource react */. Keep React and hono JSX in separate TypeScript projects: their global JSX type sets conflict in one project. The CLI does not scaffold that configuration.

The early theme script and the view toggle are static inline scripts. An application adding a CSP must allow them by hash or nonce; no CSP is configured by default. Storage values are compared against literals, never inserted into markup. With JavaScript disabled the page renders light.

On Cloudflare Workers, setu add view adds hono, JSX options, the example view files and their controller, and prints the ViewPlugin() registration. It does not add Tailwind, a stylesheet link, CSS tasks or StaticPlugin: Workers has no filesystem for that plugin. Serve CSS through Wrangler static assets and add the stylesheet link yourself.