Sub-skills
Frontend in depth
Run `cubek skill`, then `cubek skill frontend`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/frontend.md verbatim with curl; don't summarize them). Run `cubek check` after every edit until it is clean.
Depth for interactive pages: Preact islands, mount points, live queries, realtime, single-page apps. Pages render on the server; add browser code only where the page needs it. The core Skill's "Browser code and live queries" comes first.
Choose
- A widget inside a server page (toggle, counter, filter): a
*.hydrate.tsxisland. Noclient/, no mount point. - A component that needs npm or the DOM (charts, editors, maps): a mount
point +
client/<name>.tsxwith Preact (7 KB gz). - React only for React-only libraries (65 KB gz):
cubek/client/react. - A whole app behind a router: the SPA recipe below.
- Writes are POST routes: forms,
fetch, orpostForm(form).
Island (Preact hydration)
// src/components/like.hydrate.tsx
import type { JSX } from "cubek"
import { useState } from "cubek/client/preact"
export function LikeButton(props: { postId: string; likes: number; liked: boolean }): JSX.Element {
const [liked, setLiked] = useState(props.liked)
const [n, setN] = useState(props.likes)
const toggle = async (): Promise<void> => {
setLiked(!liked)
setN(liked ? n - 1 : n + 1)
await fetch(`/api/posts/${props.postId}/like`, { method: "POST" })
}
return <button class={liked ? "on" : ""} onClick={toggle}>♥ {n}</button>
}// src/pages/post.tsx
import type { JSX } from "cubek"
import { LikeButton } from "../components/like.hydrate"
export function PostPage(props: { id: string; title: string; likes: number }): JSX.Element {
return <article><h1>{props.title}</h1><LikeButton postId={props.id} likes={props.likes} liked={false} /></article>
}- Island files are cubek-ts. They import hooks (
useState,useEffect,useRef,useQuery) from "cubek/client/preact", types andqueryexports from server files, other island files, and files that import nothing from "cubek". - Browser globals (
fetch,localStorage,setTimeout) only inside handlers anduseEffect: the server renders the first state. - Props are plain data.
onClickand hooks work only in*.hydrate.tsx.
Mount point + live query (Preact)
// schema.ts
import { table, t } from "cubek"
export const messages = table("messages", { id: t.id(), room: t.string(), author: t.string(), text: t.string(), at: t.timestamp().default("now") }).index("room", "at")// src/mounts.ts
import { mount } from "cubek"
export const chat = mount<{ room: string }>("chat")// src/queries.ts
import { db, query } from "cubek"
export const roomMessages = query(async (c, args: { room: string }) => {
if (c.cookie("sid") === null) throw new Error("signed out") // check the real session here
return await db.messages.find({ where: { room: args.room }, orderBy: { at: "desc" }, limit: 50 })
})// src/app.tsx
import { app, live, Mount } from "cubek"
import { chat } from "./mounts"
import { roomMessages } from "./queries"
app.get("/rooms/:room", async (c) => {
const room = c.param("room")
return c.render(
<html><body>
<Mount at={chat} props={{ room }} live={[await live(roomMessages, { room })]}><p>Loading…</p></Mount>
</body></html>,
)
})// client/chat.tsx
/** @jsxImportSource preact */
import { render } from "preact"
import { onMount, postForm } from "cubek/client"
import { useQuery } from "cubek/client/preact"
import { chat, roomMessages } from "cubek/server"
function Chat(props: { room: string }) {
const msgs = useQuery(roomMessages, { room: props.room }) ?? []
return (
<div>
<ul>{msgs.map((m) => <li key={m.id}><b>{m.author}</b> {m.text}</li>)}</ul>
<form method="post" action={`/rooms/${props.room}/messages`} onSubmit={(e) => { e.preventDefault(); postForm(e.currentTarget); e.currentTarget.reset() }}>
<input name="text" required /><button>Send</button>
</form>
</div>
)
}
onMount(chat, (el, props) => render(<Chat {...props} />, el))- No props or args:
export const feed = mount("feed")→<Mount at={feed} />,onMount(feed, (el) => ...);export const total = query(async (c) => await db.messages.count())→live(total),useQuery(total),refresh(total). live(...)seeds the first result into the page (no loading flash); the query re-runs when its rows change and the component re-renders.- Queries only read; ≤ 256 KB per result; anyone can subscribe with any args, so check the session and ownership inside.
client/: each top-level file is a bundle unless another client file imports it; shared code inclient/lib/.bun add preactonce.subscribe(q, args, fn)/peekin plain TypeScript;onMount(m, fn, { when: "visible" })defers heavy widgets.
Live query semantics
- Shared per result, not per viewer: streams with the same query, args and values of the cookies/headers the query read share one run. A query that reads the session cookie runs once per session; one that reads none runs once for everyone.
- Tracking:
find/count/search/aggregatedepend on the whole table (any insert/update/delete re-runs);get(id)andfindOne({ where: { id } })on that row only;db.sqlon every write. Re-runs are at most 50 ms apart per key; a write during a run re-runs it after; the latest state always arrives. - A query that reads the clock (
Date.now(),new Date(), random,console.log; a session expiry check does) also re-runs every 30 s: rows leaving a time window without a write show up at that cadence. - Force a re-run:
refresh(q, args)from "cubek/client" (also exported by "cubek/client/preact" and "cubek/client/react"; in an island, from a handler or effect), e.g.setInterval(() => refresh(presence, { boardId }), 5000)for a 30 s presence window.
Async components
An async function Page(): Promise<JSX.Element> renders as JSX anywhere:
return c.render(<Page id={id} />) or <Layout><Page /></Layout>; await
inside it. No await Page({...}).
Realtime events (not tied to rows)
Server: await realtime.publish("room:1", { typing: "ana" }). Browser:
channel("room:1", (e) => ...) from "cubek/client" (or new EventSource("/_cubek/realtime?channel=room:1")). Prefer live queries for
data; realtime for ephemeral signals.
Single-page app
One mount point and a catch-all shell registered after every other route;
the router reads location, deep links get the shell. The API stays JSON
routes.
// src/mounts.ts
export const spa = mount("spa")
// src/app.tsx, after the API routes
app.get("/api/*", async (c) => c.json({ error: "not found" }, 404))
app.get("/*", async (c) => c.render(<html><body><Mount at={spa} /></body></html>))
// client/spa.tsx (React: createRoot(el).render(<App />))
onMount(spa, (el) => render(<App />, el))Assets and CSS
public/styles.css → <link rel="stylesheet" href={asset("styles.css")} />
(hashed, cached). A CSS import in a client entry is bundled with it. Initial
JS over 250 KB gzip warns: prefer islands and Preact.