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.
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).
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
childrenslot (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/returnpaths all compile, not only the firstreturn <Island id="counter" count={0}>…</Island>— hydrate on the client<ViewFragment name="rows">…</ViewFragment>— named fragment forView.renderFragment()<Stream name="slow">…</Stream>— deferred section forView.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 (HtmlStringalso skips escaping). There is nodangerouslySetInnerHTML.
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:
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:
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.
<Island id="counter" count={0}>
<button type="button" class="counter">0</button>
</Island>Register the client mount function in resources/client/:
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:
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:
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:
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:
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.