renku
v0.0.15
Published
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.
Maintainers
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 + Cloudflare Workers. wrangler and miniflare ship as transitive dependencies of renku, so you only install one package.
Install
bun add renku
bun add -d @types/bunExtend Renku's TypeScript config so JSX uses the Renku runtime and class works as an attribute:
{ "extends": "renku/tsconfig" }Add scripts. The CLI exposes renku build and renku dev; deploys still go through wrangler deploy against the generated dist/:
{
"scripts": {
"build": "renku build",
"dev": "renku dev",
"deploy": "bun run build && wrangler deploy"
}
}renku dev runs an initial build, boots Miniflare in-process, watches src/ and wrangler.jsonc, hot-reloads the Worker on every save, and injects a live-reload script into HTML responses so the browser refreshes automatically. Set PORT to override the default 8787.
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-22",
"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).
- Keep text inputs uncontrolled. Don't write
<input value={signal.get()} oninput={…}>— each keystroke updates the signal, which triggers a re-render that rebuilds the component's top-level host subtree, which replaces the input element and drops focus after the first character. Give the field anameand read its value from the form on submit:
"use client";
import type { Component } from "renku";
import { signal } from "renku/state";
export const ChatBox: Component = () => {
const messages = signal<string[]>([], "messages");
const handleSubmit = (e: Event) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const field = form.elements.namedItem("message") as HTMLInputElement | null;
const value = field?.value.trim() ?? "";
if (!value) return;
messages.set([...messages.get(), value]);
form.reset();
};
return (
<form onsubmit={handleSubmit}>
{messages.get().map((m) => (
<div>{m}</div>
))}
<input type="text" name="message" placeholder="Say something…" />
<button type="submit">Send</button>
</form>
);
};The same applies to <textarea> and <select>. If you truly need the current value reactively (live validation, for example), keep the signal but render the derived UI in a sibling node, not as value on the input.
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.
Authentication
Renku ships a declarative auth gate and a sealed-cookie session layer.
Attach auth: "required" as a reserved key inside any RouteConfig; it cascades to descendants and gates page routes with a 302 → /login?next=<path> redirect and API routes with 401. Children can override with auth: "public".
import { defineRoutes } from "renku/router";
export const routes = defineRoutes({
"/app": {
auth: "required",
layout: () => import("./pages/app/_layout.tsx"),
index: () => import("./pages/app/index.tsx"),
},
"/login": {
auth: "public",
index: () => import("./pages/login.tsx"),
},
});After a successful sign-in (passkey, OAuth, anything), seal the user into the session cookie:
import { createSession, destroySession, getSession } from "renku/auth/server";
// Login handler:
const cookie = await createSession({ id, email, name });
return new Response(null, {
status: 302,
headers: { location: "/app", "set-cookie": cookie },
});
// Logout handler:
return new Response(null, {
status: 302,
headers: { location: "/", "set-cookie": destroySession() },
});
// Anywhere inside a request:
const session = getSession(); // AuthSession | nullType your user once by augmenting the AuthSessionUser interface:
// src/types.d.ts
declare module "renku/auth/server" {
interface AuthSessionUser {
id: string;
email: string;
name: string;
}
}Requirements: set "nodejs_compat" (or "nodejs_als") in your compatibility_flags, and provide a RENKU_SESSION_KEY secret (hex-encoded AES-GCM key; openssl rand -hex 32).
Authorization beyond "is there a session" — roles, scopes, per-object permission — is intentionally left to handlers and the data-access layer.
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 # renku dev — Miniflare in-process with watch mode + live-reload
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 ([name]-[hash].[ext]). 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/, preserving the source directory undersrc/asdist/server/[dir]/[name].js. Stable paths letrenku devhot-reload without restarting workerd. Injects the server-function and client-component manifests and inlines the asset URLs (process.env.RENKU_LAYOUT_ASSETS).
It then writes dist/wrangler.jsonc with main pointing at the bundled entrypoint, assets.directory pointing at dist/client/, and no_bundle: true so the output is consumed as-is. .wrangler/deploy/config.json makes wrangler deploy pick up the generated config without extra 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.
renku/data also provides a schema-first store. In memory is the default; Durable Objects can use their SQLite storage, and browser stores can use SQLite Wasm with OPFS.
import { collection, createStore, defineSchema, type InferSchema } from "renku/data";
const userSchema = defineSchema({
type: "object",
fields: {
id: { type: "string" },
name: { type: "string" },
age: { type: "number", optional: true },
},
});
type User = InferSchema<typeof userSchema>;
const db = createStore({
users: collection({ schema: userSchema }),
});
await db.users.create({ id: "u1", name: "Ada" });
const users = await db.users.find({
where: { age: { gte: 18 } },
orderBy: { name: "asc" },
});Durable Object storage uses the same schema:
import { DurableObject } from "cloudflare:workers";
import { createDurableObjectStore } from "renku/data/cloudflare";
export class App extends DurableObject<Env> {
readonly db;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.db = createDurableObjectStore(schema, ctx.storage);
}
}Browser OPFS SQLite stores need cross-origin isolation headers:
import { createBrowserStore, withSqliteWasmHeaders } from "renku/data/browser";
const db = await createBrowserStore(schema, { filename: "app.sqlite3" });Use withSqliteWasmHeaders(response) or set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp yourself before loading a browser SQLite store.
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.
