onvibe.run

← All docs

JSX / TSX on onvibe (no build step)

onvibe serves the files you deploy and runs them in an isolated runtime — there is no build phase (no npm install, no bundler). You can still write views in JSX/TSX: Deno transpiles them on the fly. This is the recommended way to build UIs with components — prefer it over Fresh, SvelteKit, Vite or any framework that needs a local build, unless you specifically need one.

Start from it with create_project({ template: "jsx" }).

The rules (follow all of them)

  1. Put a per-file pragma at the very top of every .tsx file:

    /** @jsxImportSource npm:preact */
    

    This tells Deno which JSX runtime to use. It is per file — the runtime does NOT read deno.json, so a jsxImportSource in deno.json is ignored. Every .tsx file needs its own pragma line.

  2. Keep the entry main.ts (plain TypeScript, no JSX). onvibe's entry file is main.ts and it exports the handler. A .ts file cannot contain JSX syntax, so put all JSX in .tsx files and import from them.

  3. Use only npm: / jsr: specifiers and relative imports. e.g. npm:preact, npm:preact-render-to-string, ./views.tsx. Do NOT rely on bare specifiers mapped through deno.json imports — the runtime does not load that import map, so bare import "preact" fails. Always write the full npm:/jsr: specifier.

  4. Render server-side to a string with preact-render-to-string; the handler serves that string as HTML.

Canonical structure

views.tsx — all JSX lives here:

/** @jsxImportSource npm:preact */
import { render } from "npm:preact-render-to-string";

function Layout({ title, children }: { title: string; children: unknown }) {
  return (
    <html lang="en">
      <head><meta charset="utf-8" /><title>{title}</title></head>
      <body>{children as any}</body>
    </html>
  );
}

export function renderHome(): string {
  return "<!doctype html>" + render(
    <Layout title="My app">
      <h1>Hello</h1>
    </Layout>,
  );
}

main.ts — entry, no JSX:

import { withErrorReporting } from "./.onvibe/helpers.ts";
import { renderHome } from "./views.tsx";

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  if (req.method === "GET" && url.pathname === "/") {
    return new Response(renderHome(), {
      headers: { "content-type": "text/html; charset=utf-8" },
    });
  }
  return new Response("Not found", { status: 404 });
}

export default withErrorReporting(handler);

Notes

Why not Fresh / SvelteKit / Vite?

Those frameworks require a local build, and their bundlers emit bare import "pg"-style specifiers that depend on an import map the runtime does not load — so they need extra setup and can break. Native JSX/TSX as above needs no build and no import map, so it "just works". Reach for a framework only when you truly need its features.

Read this page as Markdown (best for LLMs) · plain text
onvibe.run · home · all docs