Skip to main content
zerotal

View

Zerotal's view layer is a JSX server-side rendering engine built into @zerotal/core — the same package that provides ctx.view(). It compiles JSX to plain HTML strings at request time: no virtual DOM, no hydration, and no client JavaScript unless you opt in.

Getting Started

The view runtime ships inside @zerotal/core, so there is no package to install or provider to register — you only point the TypeScript JSX transform at it. Set it once in tsconfig.json and every .tsx file in the project is covered:

// tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "zerotal"
  }
}

Scaffolded projects already have this — bun create zerotal writes it into the template's tsconfig.json, so the JSX examples below compile as-is.

Basic usage

Components are plain TypeScript functions that return SafeHtml. Type them with FC<Props> for the standard (props, children) => SafeHtml shape:

// resources/views/Card.tsx
import type { FC } from "zerotal/view";

interface CardProps {
  title: string;
  children?: unknown;
}

export const Card: FC<CardProps> = ({ title, children }) => (
  <div class="card">
    <h2>{title}</h2>
    <div class="card-body">{children}</div>
  </div>
);

All string children are HTML-escaped automatically. To embed pre-rendered HTML, use the safe() helper or Raw component.

Tip — Prefer native HTML attribute names (class, for, tabindex, …). React-style className and htmlFor are accepted and mapped to class/for, but native names are idiomatic here.

Warning — SSR only, no client interactivity. The runtime emits plain HTML strings; event-handler props like onClick are not serialized and are silently dropped. For interactive UI, reach for Flow (server-driven) or Inertia (React/Vue) instead.

Attributes

The runtime renders attributes from props with a few rules:

// in a component
<input type="text" value={name} disabled={isLocked} required={false} data-id={42} />
// → <input type="text" value="…" disabled data-id="42">
  • Boolean true renders the bare attribute (disabled); false, null, and undefined omit it entirely — ideal for conditional attributes.
  • Only string and number values are rendered as key="value" (both escaped).
  • className → class and htmlFor → for are mapped; every other key is used verbatim.
  • No object values. style must be a string (style="color:red"), not an object — non-string, non-number values are dropped.
  • key is ignored in output (it's a JSX hint, not an HTML attribute).

How children render

Children are rendered by type, which is the engine's security boundary:

Child valueOutput
SafeHtml (from JSX / safe())Passed through unescaped
stringHTML-escaped (treated as untrusted)
numberStringified (inert — no escaping)
true / false / null / undefinedRenders nothing
ArrayEach item rendered and concatenated

Two consequences worth remembering:

// in a component
{
  user && <Welcome name={user.name} />;
}
{
  /* false/null → nothing, so guards just work */
}

{
  count;
}
{
  /* 0 renders "0" — numbers are never blank */
}

Void elements (<br>, <img>, <input>, <hr>, <meta>, …) render without a closing tag automatically.

Rendering in a controller

Call view() from a controller action to set the response. It accepts any value whose .toString() returns HTML — a SafeHtml instance from JSX is the normal case:

// app/controllers/PostController.tsx
import { view } from "zerotal";
import type { HttpContext } from "zerotal";
import { Post } from "../models/Post.ts";
import { PostList } from "../resources/views/PostList.tsx";

export class PostController {
  async index(ctx: HttpContext) {
    const posts = await Post.query().latest().get();
    view(<PostList posts={posts} title="All Posts" />);
  }
}

Passing a component and props

You can also hand view() (or ctx.view()) the component itself plus its props. The component receives the request HttpContext as its first argument and your props as its second; route params and model bindings reach it through ctx.params:

// resources/views/Welcome.tsx
import type { HttpContext } from "zerotal";

export default function Welcome(ctx: HttpContext, { title }: { title: string }) {
  return (
    <div>
      <h1>{title}</h1>
      <p>{ctx.url.pathname}</p>
    </div>
  );
}
// routes/index.ts — the HttpContext is injected; you only pass the extra props
import { Router } from "zerotal";
import Welcome from "../resources/views/Welcome.tsx";

Router.get("/", () => view(Welcome, { title: "Welcome to Zerotal" }));

The component may be async — view() awaits it before setting the response.

Warning — JSX renders synchronously. A JSX expression evaluates to SafeHtml right away, so components used inline (<PostList … />) can't be async. If you need to load data while rendering, use the component + props form above (which awaits an async component), or do the loading in the controller or a Router.view props factory and pass the resolved data in as props.

Which view form should I use?

You have…Use
Already-rendered JSX, data loaded synchronouslyview(<Page … />)
An async component, or want the HttpContext/params injectedview(Page, props)
A page with no controller at allRouter.view
A page resolved from the filesystemFile-based pages

Layouts

Bind a shared layout to page components so every page doesn't need to manually wrap its content. defineLayout(Layout) returns a wrap(Page) factory; the wrapped page merges the layout's props (minus children) with its own:

// resources/views/layouts/AppLayout.tsx

interface AppLayoutProps {
  title: string;
  children?: unknown;
}

export function AppLayout({ title, children }: AppLayoutProps) {
  return (
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>{title} — My App</title>
        <link rel="stylesheet" href="/assets/app.css" />
      </head>
      <body>{children}</body>
    </html>
  );
}
// resources/views/About.tsx
import { defineLayout } from "zerotal/view";
import { AppLayout } from "./layouts/AppLayout.tsx";

const wrap = defineLayout(AppLayout);

// AboutPage receives { title } (from AppLayout) merged with its own props
export const AboutPage = wrap<{ heading: string }>(({ heading }) => (
  <main>
    <h1>{heading}</h1>
    <p>We build things.</p>
  </main>
));
// routes/index.ts
import { Router } from "zerotal";
import { AboutPage } from "../resources/views/About.tsx";

Router.view("/about", AboutPage, { title: "About Us", heading: "Hello" });

Router.view() calls the component and responds with the HTML string.

Controller-less pages

For server-rendered pages that don't need a controller, register the component directly with Router.view(). The third argument is the props — a static object, or a per-request factory that receives the HttpContext and may be async:

// routes/index.ts
// Static props — evaluated once at registration (marketing / info pages):
Router.view("/about", AboutPage, { title: "About Us" });

// Dynamic props — resolved per request, can be async:
Router.view("/dashboard", DashboardPage, async (ctx) => ({
  user: ctx.user,
  posts: await Post.query().where("user_id", ctx.user!.id).get(),
}));

// No props:
Router.view("/privacy", PrivacyPage);

The returned registration is chainable:

// routes/index.ts
Router.view("/terms", TermsPage)
  .name("terms") // name the route for url() generation
  .withLayout(AppLayout); // wrap the output in a layout component

File-based pages

Server-rendered file-route pages are opt-in. Register the resolver before your routes are scanned — in bootstrap/app.ts, or a provider's onRegister():

// bootstrap/app.ts
import { registerViewFileRouteResolver } from "@zerotal/core/view";

registerViewFileRouteResolver();

That is what makes the two conventions below work: a page's default export is rendered to HTML rather than treated as a plain handler, and _layout files are discovered. Without it neither applies — the default export is registered as an ordinary file handler and _layout discovery is switched off.

It is opt-in because it claims every .tsx route file's default export, which an app using file routes for plain handlers has not asked for, and because _layout discovery walks the directory tree for every scanned route.

With it registered, a page file's default export becomes a GET route. The page is a function of (http, params) and may be async:

// app/views/posts/[slug].tsx  →  GET /posts/:slug
import type { HttpContext } from "zerotal";
import { Post } from "../../models/Post.ts";

export default async function PostPage(http: HttpContext, params: { slug: string }) {
  const post = await Post.query().where("slug", params.slug).firstOrFail();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
    </article>
  );
}

// Optional — wrap every page in this directory with a layout:
export { AppLayout as layout } from "../layouts/AppLayout.tsx";

In a .tsx/.jsx file the default-exported function is detected as a page automatically. If your page lives in a plain .ts file, mark it with definePage() so the resolver recognizes it:

// app/views/greet.ts
import { definePage } from "zerotal/view";

export default definePage((http, params) => `<h1>Hello ${params.name}</h1>`);

Directory layouts — _layout.tsx

With the resolver registered, a _layout.tsx beside your pages wraps every page in that directory and below. The nearest one wins, so a section can override the root:

// app/views/_layout.tsx  →  wraps every page under app/views
export default (http: HttpContext, { children }: { children?: unknown }) => (
  <html>
    <body>
      <nav>…</nav>
      {children}
    </body>
  </html>
);
app/views/
  _layout.tsx          ← wraps everything below
  index.tsx
  admin/
    _layout.tsx        ← wraps only the admin pages
    users.tsx

It is read by its default export — the opposite of _middleware.ts, which is read by a named middleware export. Both refuse to boot on the other's spelling rather than silently applying nothing, so a mistake here is a startup error naming the file, not a page that quietly renders without its chrome.

A page can opt out with export const layout = null, or override with export { SomeLayout as layout }.

Embedding raw HTML

When you have pre-rendered markup (e.g. from a Markdown renderer) use safe() or <Raw> to bypass escaping:

// in a component
import { safe, Raw } from "zerotal/view";

// Option 1 — safe(): wrap a string as SafeHtml inline
<article>{safe(markdownToHtml(post.body))}</article>

// Option 2 — Raw component: composable, e.g. passed as a child
<article><Raw html={markdownToHtml(post.body)} /></article>

// Option 3 — dangerouslySetInnerHTML on an element (best for a single element)
<article dangerouslySetInnerHTML={{ __html: markdownToHtml(post.body) }} />

dangerouslySetInnerHTML replaces the element's children entirely.

Danger — All three bypass escaping. Only pass HTML you trust or have sanitized; rendering untrusted markup this way is an XSS hole.

Escaping outside JSX

esc() escapes a value for use in raw string templates, where automatic JSX escaping isn't available:

// in a helper
import { esc } from "zerotal/view";

const snippet = `<p>Hello, ${esc(user.name)}!</p>`;

Fragments

Use Fragment (or the <>...</> shorthand) to return multiple root elements:

// in a component
import { Fragment } from "zerotal/view";

const Items = ({ items }: { items: string[] }) => (
  <>
    {items.map((item) => (
      <li>{item}</li>
    ))}
  </>
);

Testing

Set your suite up once as described in Testing. A view is a function returning a node that stringifies, so most of it tests without a server.

Render the component and assert on the string. No request, no router, no browser:

// tests/views/PostCard.test.ts
import { test, expect } from "bun:test";
import { PostCard } from "../../resources/views/PostCard.tsx";

test("renders the title and author", () => {
  const html = String(PostCard({ post: { title: "Hello", author: "Jane" } }));

  expect(html).toContain("Hello");
  expect(html).toContain("Jane");
});

Escaping is the test that matters. Every view that renders user input has one job beyond looking right, and it is the job that becomes a security incident:

// tests/views/PostCard.test.ts
test("escapes markup in user-supplied text", () => {
  const html = String(PostCard({ post: { title: "<script>alert(1)</script>" } }));

  expect(html).not.toContain("<script>alert(1)</script>");
  expect(html).toContain("&lt;script&gt;");
});

Write the same test for anything passed through safe() or <Raw> — those opt out of escaping deliberately, so the test documents that the value is trusted and fails if someone later routes user input into it.

Through a route, assert the rendered text rather than the markup around it. assertSee survives a class rename; a full-HTML comparison does not:

// tests/http/posts.test.ts
const res = await app.get("/posts");

res.assertOk();
res.assertSee("Hello");
res.assertDontSee("Draft"); // unpublished posts stay hidden

Note — assertSee matches anywhere in the body, so a short string can match an attribute or a class name and pass for the wrong reason. Assert on something distinctive enough to only appear in the content you mean.

References

Helpers

ExportSignatureDescription
safe(html)(html: string) => SafeHtmlWrap a trusted HTML string as SafeHtml so it isn't re-escaped.
Raw({ html })(props: { html: string }) => SafeHtmlComponent form of safe() — composable as a child.
esc(value)(value: unknown) => stringEscape a value for raw string templates (not needed inside JSX).
Fragment(props: { children?: unknown }) => SafeHtmlGroup multiple roots; <>…</> is shorthand.
defineLayout(L)(Layout) => (Page) => ComponentBind a layout to pages; returns a wrap(Page) factory.
definePage(fn)(fn: (http, params) => SafeHtml | string) => fnMark a (http, params) function as a file-route page (.ts files).

Response and routing helpers

MemberSignatureDescription
viewview(markup, status?): voidSet a pre-rendered view as the current response.
viewview(component, props?, status?): void | Promise<void>Render a component called with the HttpContext and your props.
Router.viewRouter.view(path, component, props?, middleware?): ViewRegistrationRegister a controller-less GET route that renders a view component.

Types

ExportDescription
SafeHtmlOpaque wrapper for already-escaped HTML — the return type of every JSX expression.
HtmlAlias for SafeHtml — useful in controller return-type annotations.
FC<P>Functional component type — (props: P & { children?: unknown }) => SafeHtml.
ChildrenType for the children prop.
HttpContext<T>The request context a view component or controller action receives; route params and model bindings live on ctx.params.

Types

ViewComponent is what view() accepts — a function taking the request context and your props, returning markup. ViewLayout is the wrapper form a layout takes. Both are exported so a helper that returns a component, or a registry that holds several, can be typed.

Next steps

  • Flow — server-driven interactive components over WebSocket.
  • Inertia — React/Vue SPA pages backed by your controllers.
  • Routing — Router.view() and file-based page routing.
  • Responses — view() and the other response helpers.
  • Controllers — move view logic into controller actions.