Skip to content

Views ​

Register ViewServiceProvider and add config/views.ts. Views live in resources/views/ as .tsx modules. The default extension is .tsx. .tyr templates are not supported in 5.0.

typescript
import { View, setViewApplication } from '@pondoknusa/core';
import { Response } from '@pondoknusa/http';

setViewApplication(app);

Route.get('/', async () =>
  Response.html(await View.render('welcome', { name: 'Ada' })),
);

View module shape ​

Each view exports a default render function. Import view from @pondoknusa/views/jsx so the editor loads Pondoknusa JSX types (HTML class/for, Island, …) instead of React. PascalCase tags resolve to other views (Alert → components/alert.tsx, AppLayout → layouts/app.tsx).

tsx
import { view } from '@pondoknusa/views/jsx';

interface Props {
  name?: string;
  showDetails?: boolean;
  users?: string[];
  greeting?: string;
}

export default view(function render({ name, showDetails, users = [], greeting }: Props) {
  return (
    <AppLayout title="Welcome">
      <h1>Hello {name}</h1>
      {showDetails && <p>Users: {users.length}</p>}
      <ul>
        {users.map((user) => (
          <li>{user}</li>
        ))}
      </ul>
      <Alert message={greeting} />
    </AppLayout>
  );
});

export default function render(...) still compiles. view() is an identity helper: the Oxc compiler unwraps it. File tags (Alert, AppLayout) typecheck from pondoknusa view:types (types/view-jsx.generated.d.ts) plus types/pondoknusa-jsx.d.ts.

  • {name} — escaped output
  • Nested JSX under a component becomes the children slot (already-rendered HTML)
  • <Slot name="footer">…</Slot> — named slot
  • {items.map((item) => <li>{item}</li>)} — loops (filter().map(), items?.map(), and zero-parameter callbacks work too)
  • {cond && <p>…</p>} and ternaries — conditionals
  • Early if / return paths all compile, not only the first return
  • <Island id="counter" count={0}>…</Island> — hydrate on the client
  • <ViewFragment name="rows">…</ViewFragment> — named fragment for View.renderFragment()
  • <Stream name="slow">…</Stream> — deferred section for View.renderStream() / View.streamSsr()
  • <Csrf />, <Method verb="PUT" />, <Vite entry="resources/client/app.ts" />, <Json value={data} />, <Lang key="welcome" />, <Once>…</Once> — form and asset helpers
  • <Raw html={trusted} /> — already-trusted markup (HtmlString also skips escaping). There is no dangerouslySetInnerHTML.

Use HTML attribute names (class, for). className and htmlFor are remapped at compile time. Host events such as onClick are compile/lint errors; put interactivity in an island.

This is not React. PascalCase tags are files, not imports. tsconfig should use "jsx": "preserve" (do not set jsxImportSource to React). Put @pondoknusa/views/jsx in an import or in types/pondoknusa-jsx.d.ts — not in compilerOptions.types, which only looks up @types/* packages. pondoknusa view:types writes types/view-jsx.generated.d.ts so <Alert> typechecks without importing the file.

const / let / var bindings before return are in the render context. This works:

tsx
export default function render({ title }: Props) {
  const styles = `body { color: red }`;
  return <AppLayout styles={styles}><h1>{title}</h1></AppLayout>;
}

Locals evaluate in order. A later local can use props and earlier locals. Put side effects and shared helpers in imported modules.

Reusable UI under a namespace uses member tags: <Ui.Button label="Save" /> resolves to ui::components.button.

Generate views with pondoknusa make:view pages.about.

Trust boundary ​

View expressions run as developer-trusted code (Function for non-trivial paths). Treat .tsx views like application source. Do not compile or render markup supplied by end users. {name} is escaped. Slot children is HTML the compiler already rendered.

Server-side rendering ​

Pondoknusa supports progressive enhancement: render HTML on the server, then hydrate interactive regions on the client.

Document shell ​

Use Response.ssr() to wrap a rendered view in a complete HTML document and inject the hydration manifest:

typescript
import { Route, View } from '@pondoknusa/core';
import { Response } from '@pondoknusa/http';

Route.get('/', async () => {
  const html = await View.render('welcome', { name: 'Ada' });

  return Response.ssr(html, {
    hydrationManifest: View.getHydrationManifest(),
  });
});

buildSsrDocument() from @pondoknusa/http performs the same wrapping when you need the HTML string without building a Response.

The manifest is serialized into <script type="application/json" id="pn-hydration"> before </body>.

Islands ​

Mark interactive regions with <Island>. The server renders fallback HTML. The client mounts a registered handler for the same id.

tsx
<Island id="counter" count={0}>
  <button type="button" class="counter">0</button>
</Island>

Register the client mount function in resources/client/:

typescript
import { registerIsland } from '@pondoknusa/ssr';

registerIsland('counter', ({ element, props }) => {
  const button = element.querySelector('button');
  let count = Number(props.count ?? 0);

  button?.addEventListener('click', () => {
    count += 1;
    if (button) button.textContent = String(count);
  });
});

Bootstrap hydration after the page loads:

typescript
import { hydrate } from '@pondoknusa/ssr';

hydrate();

hydrate() reads data-pn-island markers (and the optional #pn-hydration manifest) and calls the matching registerIsland() handler.

Scaffold a new island with pondoknusa make:island counter. That creates resources/views/islands/counter.tsx, resources/client/islands/counter.ts, and registers the client mount in your bundle entry.

Streaming layouts ​

Mark deferred body sections with <Stream>. The shell is flushed first; each named section is rendered (or supplied by a handler) later:

tsx
export default function render({ name }: { name?: string }) {
  return (
    <>
      <h1>Streaming {name}</h1>
      <Stream name="slow">
        <p>Slow section rendered after the shell.</p>
      </Stream>
    </>
  );
}

Return a chunked SSR response in one call — no manual async iteration in the controller:

typescript
Route.get('/dashboard', () =>
  View.streamSsr('dashboard', context, {
    sidebar: async () => '<aside>Fresh sidebar</aside>',
  }),
);

View.streamSsr() pipes View.renderStream() through Response.ssrStream(), which flushes the document shell (<head> + CSS links) before the first view chunk, then streams body content, and injects the hydration manifest after the view stream completes. The Node HTTP adapter flushes each chunk as it is produced.

Lower-level control is still available when you need it:

typescript
return Response.ssrStream(View.renderStream('dashboard', context, handlers), {
  title: 'Dashboard',
  hydrationManifest: () => View.getHydrationManifest(),
});

View.renderStream() yields HTML in document order: shell markup, then each stream section as it resolves.

await View.catalog() returns { components, islands } so tooling can see which views declare each island id and whether a client mount exists. For design-system JSON export, use pondoknusa view:catalog --json.

Released under the MIT License.