JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 19
  • Score
    100M100P100Q65826F
  • License SEE LICENSE IN LICENSE.md

Server-first JSX framework for Cloudflare Workers, built with Bun. Plain-object routes, server-rendered function components, "use client" opt-in interactivity, and server functions that cross the network automatically.

Package Exports

  • renku
  • renku/build/bun
  • renku/client
  • renku/data
  • renku/dom
  • renku/internal/bootstrap
  • renku/jsx
  • renku/jsx-dev-runtime
  • renku/jsx-runtime
  • renku/router
  • renku/server
  • renku/state
  • renku/streams
  • renku/tsconfig
  • renku/ui
  • renku/ui/theme

Readme

Renku

Server-first JSX framework for Cloudflare Workers. Routes are plain objects, pages are function components rendered on the server, and interactivity opts in with "use client". Server functions cross the network automatically when imported from client modules.

Built for Bun + Wrangler.

Install

bun add renku
bun add -d wrangler @types/bun

Extend Renku's TypeScript config so JSX uses the Renku runtime and class works as an attribute:

{ "extends": "renku/tsconfig" }

Add scripts. renku build is the only CLI subcommand; dev and deploy are just Wrangler against the generated dist/:

{
  "scripts": {
    "build": "renku build",
    "dev": "bun run build && wrangler dev --no-bundle",
    "deploy": "bun run build && wrangler deploy"
  }
}

Create wrangler.jsonc with your Worker entrypoint. renku build reads this file from process.cwd().

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-04-14",
  "workers_dev": true,
  "observability": { "enabled": true },
}

Project Layout

src/
  index.ts          // Worker entrypoint
  routes.ts         // Route tree
  style.css         // Imported by layout; picked up by Tailwind plugin
  pages/
    layout.tsx
    index.tsx
    about.tsx
    products/
      _layout.tsx
      [id].tsx
  components/
    counter.tsx     // "use client"  (.tsx)
  server/
    get-message.ts  // "use server"  (.ts)

The filenames are a convention — Renku has no file-system router. Everything is wired up in routes.ts. The only rule the scanner cares about is extensions: "use client" is recognized in .tsx files and "use server" in .ts files.

Worker Entrypoint

Delegate requests to fetchHandler() from renku/router:

import { fetchHandler } from "renku/router";

import { routes } from "./routes";

export default {
  async fetch(req: Request, _env: Env): Promise<Response> {
    return fetchHandler(req, routes);
  },
} satisfies ExportedHandler<Env>;

Durable Objects or other Worker exports live next to default as usual. You can run your own logic before falling through to fetchHandler (WebSocket upgrades, custom subdomain routing, etc.).

Routes

defineRoutes() takes a plain object. Special keys are layout and index; every other string key is a path. HTTP-method keys (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) mark an object as an API handler.

import { version } from "renku";
import { defineRoutes } from "renku/router";

import IndexPage from "./pages";
import Layout from "./pages/layout";

export const routes = defineRoutes({
  layout: Layout,
  index: IndexPage,
  "/about": () => import("./pages/about.tsx"),
  "/products/:id": () => import("./pages/products/[id].tsx"),
  "/api": {
    "/version": {
      GET: () => new Response(version),
    },
    "/product/:id": {
      GET: (_req, { params }) => new Response(`Product: ${params.id}`),
    },
  },
});

Rules the router follows:

  • Route values can be a component, a lazy () => import("..."), an API handler object, or a nested defineRoutes config. layout and index themselves can also be lazy imports.
  • Path keys can be written with or without a leading slash ("/about" and "about" are equivalent).
  • Dynamic segments :id are passed as component props and are typed through RouteComponentProps<{ id: string }>. API handlers receive them on ctx.params.
  • More specific routes win over dynamic ones: more static segments first, then longer matches, then declaration order.
  • HTTP method handlers: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS. HEAD falls back to GET with the body stripped. Requests whose method isn't declared get 405 Method Not Allowed with an Allow header listing the methods that are.
  • layout wraps the current subtree; nest routes to compose layouts.

Nested subtrees can live in their own files:

// src/routes.ts
export const routes = defineRoutes({
  layout: RootLayout,
  "/": webRoutes,
  "/app": appRoutes,
});

// src/app/routes.ts
export const appRoutes = defineRoutes({
  layout: () => import("./_layout.tsx"),
  index: () => import("./index.tsx"),
  "/settings": () => import("./settings.tsx"),
});

Layouts And Pages

Pages and layouts are function components. They may be async on the server.

// pages/layout.tsx
import type { LayoutComponent } from "renku";

import "../style.css";

const Layout: LayoutComponent = ({ children, scripts, stylesheets }) => {
  return (
    <html>
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>My App</title>
        {stylesheets.map((s) => (
          <link key={s} rel="stylesheet" href={s} />
        ))}
        {scripts.map((s) => (
          <script key={s} type="module" src={s} async></script>
        ))}
      </head>
      <body>
        <main>{children}</main>
      </body>
    </html>
  );
};

export default Layout;

The root layout is where you emit <html>, inject stylesheets, and load the client bootstrap. Nested layouts just wrap children — they still receive the same props but rarely need them.

// pages/index.tsx
export default function IndexPage() {
  return (
    <div>
      <h1 class="text-4xl">Welcome</h1>
      <a href="/about">About</a>
    </div>
  );
}

Pages with dynamic segments receive them as typed props:

import type { RouteComponent } from "renku/router";

const ProductPage: RouteComponent<{ id: string }> = ({ id }) => {
  return <h1>Product {id}</h1>;
};

export default ProductPage;

Server components can be async — their output streams in after the rest of the tree:

import type { Component } from "renku";

export const Async: Component<{ delay: number }> = async ({ delay }) => {
  await new Promise((r) => setTimeout(r, delay));
  return <div>Done after {delay}ms</div>;
};

JSX Dialect

  • Write class (not className), onclick / onsubmit / oninput (lowercase, like the DOM), and boolean attributes as disabled={true} / disabled={false}.
  • class accepts a string or an array of string | null | undefined that gets joined with spaces (nullish values dropped).
  • Functions on host elements only run inside "use client" modules — server-rendered handlers are dropped silently.
  • Tailwind is supported out of the box via bun-plugin-tailwind; just import your CSS from a layout. Every .css file under src/ is picked up automatically.

Client Components

Put "use client" as the very first line of a .tsx module. The component renders on the server as an HTML comment placeholder, then hydrates in the browser with the same props.

"use client";

import type { Component } from "renku";
import { once, signal } from "renku/state";
import { Button } from "renku/ui";

import { getMessage } from "../server/get-message";

export const Counter: Component<{ count: number }> = ({ count }) => {
  const value = signal(count, "count");

  once(() => {
    void getMessage().then((message) => console.log("Server said:", message));
  }, "server-message");

  return <Button onclick={() => value.set(value.get() + 1)}>Count: {value.get()}</Button>;
};

renku/state

All three hooks can only be called during client render. Pass a key to give a slot a stable identity across re-renders — without a key, slots are tracked positionally like React hooks.

API What it does
signal(initialValue, key?) Reactive value. Returns a { get, set }-style signal (from signal-polyfill).
once(effect, key?) Runs effect exactly once on mount. If effect returns a function it runs on unmount.
task(asyncFn, key?) Runs asyncFn({ signal }) and exposes { loading, error, data }. Changing the key aborts and restarts.
"use client";

import type { Component } from "renku";
import { signal, task } from "renku/state";

const wait = (ms: number, abort: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const id = setTimeout(resolve, ms);
    abort.addEventListener(
      "abort",
      () => {
        clearTimeout(id);
        reject(abort.reason);
      },
      { once: true },
    );
  });

export const TaskDemo: Component = () => {
  const run = signal(0, "run");
  const result = task(async ({ signal }) => {
    await wait(1500, signal);
    return "done";
  }, run.get());

  return (
    <div>
      <button onclick={() => run.set(run.get() + 1)}>Reload</button>
      {result.loading ? <p>Loading</p> : null}
      {result.error ? <p>Error</p> : null}
      {!result.loading && !result.error ? <p>{result.data}</p> : null}
    </div>
  );
};

Rules of thumb:

  • Keep the client tree small. Most of the page should stay on the server.
  • Pass only serializable props through "use client" boundaries (strings, numbers, plain objects, arrays).
  • Use keys when the slot's identity matters across re-renders (forms, lists, anything reset-sensitive).

Server Functions

Put "use server" as the very first line of a .ts module (not .tsx — the build scanner only recognizes "use server" in .ts files). Exports become callable from the server directly and, when imported from a client module, the build rewrites the import into an HTTP RPC call to /__renku/server-functions (via capnweb).

"use server";

import { env } from "cloudflare:workers";

export const getMessage = async () => {
  const hello = await env.MY_DURABLE_OBJECT.getByName("ID").sayHello();
  return `Hello from server: ${hello}`;
};
"use client";

import { getMessage } from "../server/get-message";
// In the browser, this is a fetch under the hood. On the server it's a direct call.

Arguments and return values must be JSON-serializable.

UI Primitives

renku/ui ships a small, theme-aware Button and control classNames:

import { Button, buttonClassName } from "renku/ui";

<Button variant="default" size="sm" onclick={handleClick}>Save</Button>
<a class={buttonClassName({ variant: "ghost" })} href="/docs">Docs</a>

Import the theme tokens once from your global stylesheet:

@import "renku/ui/theme";

Build And Run

From the app root (where wrangler.jsonc lives):

bun run build   # renku build
bun run dev     # wrangler dev --no-bundle against ./dist
bun run deploy  # wrangler deploy against ./dist

renku build does the following:

  1. Scan — walks src/ and marks every .tsx starting with "use client" as a client module, every .ts starting with "use server" as a server-function module, and every .css file as a Tailwind input.
  2. Client — bundles the Renku bootstrap plus every client module into dist/client/ with hashed filenames. CSS is built separately through bun-plugin-tailwind. The resulting hashed URLs are exposed to the root layout as scripts and stylesheets.
  3. Server — bundles the Worker entrypoint and every server-function module into dist/server/, injecting the server-function and client-component manifests and inlining the asset URLs (process.env.RENKU_LAYOUT_ASSETS).

It then writes dist/wrangler.jsonc with main pointing at the bundled entrypoint and assets.directory pointing at dist/client/, plus .wrangler/deploy/config.json so Wrangler picks up that generated config. wrangler dev --no-bundle and wrangler deploy both work against dist/ without additional flags.

Durable Streams

renku/streams ships a DurableStream Durable Object base class for streaming / long-lived connections. Export it (or your subclass of it) from your Worker and bind it in wrangler.jsonc like any other DO.

Schemas

renku/data provides a small declarative Schema type (object, array, string, number, boolean, null, literal, union) and toStandardSchema(schema), which adapts it to the Standard Schema spec so it plugs into any library that consumes Standard Schemas.

Gotchas

  • "use client" / "use server" must be the first statement in the file — not after imports.
  • "use client" is only recognized in .tsx files; "use server" only in .ts files. Put your server functions in plain .ts modules.
  • signal, once, and task throw if called outside a client render.
  • renku build reads wrangler.jsonc from process.cwd(), so always run it from the app root.
  • Event handlers on server components do nothing; move them into a "use client" module.
  • Props crossing "use client" / "use server" boundaries must be JSON-serializable.
  • Use class — not className — everywhere.