Sub-skills

Frontend in depth

For your agentCopy this and give it to the AI you're building with.
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.tsx island. No client/, no mount point.
  • A component that needs npm or the DOM (charts, editors, maps): a mount point + client/<name>.tsx with 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, or postForm(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 and query exports from server files, other island files, and files that import nothing from "cubek".
  • Browser globals (fetch, localStorage, setTimeout) only inside handlers and useEffect: the server renders the first state.
  • Props are plain data. onClick and 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 in client/lib/. bun add preact once.
  • subscribe(q, args, fn) / peek in 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/aggregate depend on the whole table (any insert/update/delete re-runs); get(id) and findOne({ where: { id } }) on that row only; db.sql on 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.