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/bunExtend 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 nesteddefineRoutesconfig.layoutandindexthemselves can also be lazy imports. - Path keys can be written with or without a leading slash (
"/about"and"about"are equivalent). - Dynamic segments
:idare passed as component props and are typed throughRouteComponentProps<{ id: string }>. API handlers receive them onctx.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.HEADfalls back toGETwith the body stripped. Requests whose method isn't declared get405 Method Not Allowedwith anAllowheader listing the methods that are. layoutwraps 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(notclassName),onclick/onsubmit/oninput(lowercase, like the DOM), and boolean attributes asdisabled={true}/disabled={false}. classaccepts a string or an array ofstring | null | undefinedthat 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.cssfile undersrc/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 ./distrenku build does the following:
- Scan — walks
src/and marks every.tsxstarting with"use client"as a client module, every.tsstarting with"use server"as a server-function module, and every.cssfile as a Tailwind input. - Client — bundles the Renku bootstrap plus every client module into
dist/client/with hashed filenames. CSS is built separately throughbun-plugin-tailwind. The resulting hashed URLs are exposed to the root layout asscriptsandstylesheets. - 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.tsxfiles;"use server"only in.tsfiles. Put your server functions in plain.tsmodules.signal,once, andtaskthrow if called outside a client render.renku buildreadswrangler.jsoncfromprocess.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— notclassName— everywhere.