cloud-app
Building Apps on the Cloud
This skill covers everything you need to build a complete app. For platform overview and auth concepts, see the cloud skill. For deployment, see cloud-ops.
Detailed references: Backend patterns →
references/backend.md| Frontend patterns →references/frontend.md
What Belongs In An App — And What Does NOT
Apps are domain features on top of the platform. They must not redefine platform primitives.
Keep out of apps — these live in packages/cloud/ (core):
- Auth flows, session semantics, role/permission logic — every container shares the same auth model. A new login flow or role type is a core change, not an app change.
- The
auth.*schema and anything that writes to it — user/group/access/account-request/deleted-account tables are owned by core. Apps referenceauth.users(id)via foreign keys; they never migrate or mutate those tables directly. - Account lifecycle, IPA sync, provider switching, magic-link issuance — these are platform invariants.
The existing accounts app (packages/accounts/) is pure admin UI backed by @valentinkolb/cloud/services/accounts. It owns no schema, no service layer, no lifecycle. It exists so operators can fork or replace the admin frontend without touching auth semantics. If you find yourself wanting to add auth logic there, move it to packages/cloud/src/services/ first — then consume it from the app.
Good app candidates: domain features (files, notebooks, spaces, quotes, weather), tools, reporting. Anything where swapping the app out wouldn't change how users log in or what roles mean.
App Directory Structure
Every app follows this skeleton. Reference apps: cloud-template (canonical standalone reference — tenancy + items + permissions + admin + widget + email), weather (simple), faq (CRUD/admin), contacts (permissions/detail panels).
packages/my-app/
├── package.json # workspace manifest
├── tsconfig.json # @/* and @valentinkolb/cloud/* path aliases
├── tsconfig.typecheck.json
└── src/
├── index.ts # app.start() — the entry point
├── config.ts # defineApp() — identity, SSR config, settings, widgets
├── api/
│ ├── index.ts # Hono router (mounts sub-routers, exports ApiType)
│ ├── client.ts # Typed Hono client for frontend
│ ├── widgets.ts # Dashboard widget endpoints
│ └── items.ts # Route handlers (one file per resource)
├── contracts.ts # Zod schemas for input/output validation
├── migrate.ts # Database migrations (CREATE SCHEMA/TABLE IF NOT EXISTS)
├── service/ # Business logic (stateless functions)
│ ├── index.ts # Service namespace export
│ └── items.ts
├── styles/
│ └── app.css # Tailwind entrypoint (required by CSS preloader)
└── frontend/
├── index.ts # Explicit page route mapping (SSR pages to Hono routes)
├── page.tsx # Root page (SSR)
├── [id]/
│ └── page.tsx # Dynamic route page
└── _components/
├── ItemList.island.tsx # Interactive client component
└── ItemFilters.island.tsx
Also required when adding a new app:
- Add a service block in
compose.dev.yml(seecloud-opsskill). - Add a
COPY packages/my-app/package.json packages/my-app/line inDockerfile.devso the install layer caches the new workspace.
The app self-registers in the Redis app registry via createHeartbeat() on startup; the gateway picks it up within ~5 s. There is no central registration file.
The App Entry Point
config.ts — App Definition
import { defineApp } from "@valentinkolb/cloud";
export const app = defineApp({
id: "my-app", // unique, used in URLs and registry
name: "My App", // display name
icon: "ti ti-star", // Tabler icon class
description: "Short description.",
basePath: "/app/my-app", // SSR asset URL prefix
baseUrl: "http://app-my-app:3000", // container URL for service registry
adminHref: "/admin/my-app", // optional admin page link
nav: {
href: "/app/my-app", // primary nav link
match: "/app/my-app", // active-state path matching
section: "primary", // "primary" | "more" | "hidden"
requiresAuth: true,
requiresRoles: ["user"], // optional role filter
},
widgets: [{ id: "today", path: "/api/my-app/widget/today" }],
settings: {
"my-app.feature_enabled": {
kind: "boolean",
label: "Enable feature X",
default: true,
description: "Whether feature X is active.",
},
},
// Opt in to the platform-wide API docs aggregator (the api-docs app at
// /app/api-docs). Pair this with `app.start({ openapi: <api router> })`
// — defineApp generates an OpenAPI 3.x spec from that router at boot,
// mounts it on the framework server before the user fetch (so it's
// public), and advertises the URL via the registry. Apps without an
// API surface (pages-only) omit this field.
openapi: "/api/my-app/openapi.json",
// Top-level URL prefixes the gateway routes to this container. Standard
// four-prefix scheme; specials list whatever they actually own.
routes: ["/api/my-app", "/app/my-app", "/admin/my-app", "/public/my-app"],
});
export const { ssr, plugin } = app;
defineApp() creates the SSR config, Bun plugin for island bundling, and the ssr page handler wrapper used in page files. All app identity, widget endpoints, and per-app settings live here — one place. The settings map is typed: keys are exposed on c.get("settings") for any route using Hono<AppContext<typeof app>>.
index.ts — App Bootstrap
import { app } from "./config";
import { Hono } from "hono";
import { middleware, type AuthContext } from "@valentinkolb/cloud/server";
import apiRoutes from "./api";
import pageRoutes, { adminPages } from "./frontend";
import { myService } from "./service";
import { migrate } from "./migrate";
// Compose your own router — the framework no longer injects middleware
// implicitly. Register what you need from `middleware.*` and pass the
// resulting Hono instance's `.fetch` to `app.start()`.
const router = new Hono<AuthContext>()
.use("*", middleware.runtime()) // c.get("runtime") — required by Layout/Sidebar
.use("*", middleware.settings()) // c.get("settings") — typed snapshot
.route("/api/my-app", apiRoutes)
.route("/app/my-app", pageRoutes)
.route("/admin/my-app", adminPages);
export default await app.start({
fetch: router.fetch,
// Pair with defineApp's `openapi: "/api/my-app/openapi.json"`. The
// framework generates the spec from this router at boot and serves it
// at the configured URL (public, before any auth middleware).
openapi: apiRoutes,
lifecycle: {
setup: async () => { await migrate(); },
start: async (ctx) => { /* start background jobs */ },
stop: async (ctx) => { /* cleanup */ },
},
capabilities: {
search: {
tags: ["items"],
help: "Search items",
run: async ({ query, tags, limit, ctx }) => {
// return AppSearchResult[]
},
},
},
});
export { myService as service };
export type { ApiType } from "./api";
styles/app.css — Tailwind Entrypoint
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Required by the CSS preloader. Every app must have this file.
Backend
Service Layer
All business logic lives in the service — routes are thin wrappers that validate input, call the service, and return the result.
Pattern: Stateless namespaced objects with async functions.
// service/items.ts
import { sql } from "bun";
import { ok, fail, type Result } from "@valentinkolb/stdlib";
import type { PaginationParams } from "@valentinkolb/cloud/contracts";
import { logger } from "@valentinkolb/cloud/services";
import { toPgTextArray, escapeLikePattern } from "@valentinkolb/cloud/services";
const log = logger("my-app:items");
type DbRow = Record<string, unknown>;
const mapRow = (row: DbRow): Item => ({
id: row.id as string,
title: row.title as string,
createdAt: (row.created_at as Date).toISOString(),
});
export const items = {
list: async (pagination: PaginationParams, search?: string): Promise<{ items: Item[]; total: number }> => {
const { offset, perPage } = pagination;
const conditions: any[] = [sql`TRUE`];
if (search) {
const pattern = `%${escapeLikePattern(search.toLowerCase())}%`;
conditions.push(sql`LOWER(title) LIKE ${pattern} ESCAPE '\\'`);
}
const where = conditions.reduce((acc, cond) => sql`${acc} AND ${cond}`);
const [countRows, dataRows] = await Promise.all([
sql<DbRow[]>`SELECT COUNT(*)::int AS total FROM my_app.items WHERE ${where}`,
sql<DbRow[]>`SELECT * FROM my_app.items WHERE ${where} ORDER BY created_at DESC LIMIT ${perPage} OFFSET ${offset}`,
]);
return { items: dataRows.map(mapRow), total: countRows[0]?.total ?? 0 };
},
create: async (data: CreateItem): Promise<Result<Item>> => {
const rows = await sql<DbRow[]>`
INSERT INTO my_app.items (title, description)
VALUES (${data.title}, ${data.description})
RETURNING *
`;
if (!rows[0]) return fail({ code: "INTERNAL", message: "Insert failed", status: 500 });
log.info("Item created", { id: rows[0].id });
return ok(mapRow(rows[0]));
},
};
Key conventions:
- Import
sqldirectly from"bun"— no ORM, no query builder - Define
type DbRow = Record<string, unknown>and cast in mapper functions - Use
Result<T>(from@valentinkolb/stdlib) for operations that can fail:ok(data)/fail(error) - Services are stateless — no class instances, no constructor injection
- Use
toPgTextArray(),toPgUuidArray(),escapeLikePattern()from@valentinkolb/cloud/services - Use
ok,fail,errfrom@valentinkolb/cloud/server(re-exported from stdlib)
SQL Patterns
Dynamic WHERE, CTEs, JSONB, recursive queries →
references/backend.md
Always use Bun's sql template tag for parameterized queries:
import { sql } from "bun";
// Simple query
const rows = await sql<DbRow[]>`SELECT * FROM my_app.items WHERE id = ${id}`;
// Dynamic conditions
const conditions: any[] = [sql`TRUE`];
if (filter.status) conditions.push(sql`status = ${filter.status}`);
if (filter.ids) conditions.push(sql`id = ANY(${toPgUuidArray(filter.ids)}::uuid[])`);
const where = conditions.reduce((acc, cond) => sql`${acc} AND ${cond}`);
// Pagination (always use the shared helper)
import { parsePagination, createPagination } from "@valentinkolb/cloud/contracts";
const pagination = parsePagination(query);
const rows = await sql`... LIMIT ${pagination.perPage} OFFSET ${pagination.offset}`;
const paginationResult = createPagination(pagination, total);
Migrations
Each app owns its own PostgreSQL schema. Migrations live in migrate.ts and run in lifecycle.setup():
// migrate.ts
import { sql } from "bun";
export const migrate = async () => {
await sql`CREATE SCHEMA IF NOT EXISTS my_app`.simple();
console.log(" ✓ my_app schema");
await sql`
CREATE TABLE IF NOT EXISTS my_app.items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`.simple();
console.log(" ✓ my_app.items table");
await sql`CREATE INDEX IF NOT EXISTS idx_items_owner ON my_app.items (owner_id)`.simple();
await sql`CREATE INDEX IF NOT EXISTS idx_items_created ON my_app.items (created_at DESC)`.simple();
};
Convention: Use CREATE ... IF NOT EXISTS and .simple() — migrations are idempotent and run on every startup.
Warning: Never add and drop temporary columns in migrations. PostgreSQL counts dropped columns towards the maximum column limit (1600). Repeated add/drop cycles across deployments can exhaust this limit even though the visible column count is low.
App-owned settings are declared inside defineApp({ settings: { ... } }) (see § config.ts above). The platform registers them automatically; they appear in /admin/settings grouped by the dotted-key prefix and become typed on c.get("settings") for routes using Hono<AppContext<typeof app>>.
Hono API Routes
// api/index.ts
import { Hono } from "hono";
import { rateLimit } from "@valentinkolb/cloud/server";
import itemsRoutes from "./items";
const app = new Hono()
.use(rateLimit())
.route("/items", itemsRoutes);
export default app;
export type ApiType = typeof app; // ← this type powers the frontend client
// api/items.ts
import { Hono } from "hono";
import { auth, v, respond, ok, jsonResponse } from "@valentinkolb/cloud/server";
import { describeRoute } from "hono-openapi";
import type { AuthContext } from "@valentinkolb/cloud/server";
import { parsePagination, createPagination, PaginationQuerySchema, PaginationResponseSchema, ErrorResponseSchema } from "@valentinkolb/cloud/contracts";
import { ItemSchema, CreateItemSchema, ItemListResponseSchema } from "../contracts";
import { items } from "../service";
import { z } from "zod";
const app = new Hono<AuthContext>()
.use(auth.requireRole("authenticated"))
.get(
"/",
describeRoute({
tags: ["Items"],
summary: "List items",
responses: {
200: jsonResponse(ItemListResponseSchema, "Paginated items list"),
},
}),
v("query", PaginationQuerySchema.extend({ search: z.string().optional() })),
async (c) => {
const query = c.req.valid("query");
const pagination = parsePagination(query);
const { items: data, total } = await items.list(pagination, query.search);
return c.json({ items: data, pagination: createPagination(pagination, total) });
},
)
.post(
"/",
describeRoute({
tags: ["Items"],
summary: "Create item",
responses: {
201: jsonResponse(ItemSchema, "Created item"),
400: jsonResponse(ErrorResponseSchema, "Validation error"),
},
}),
v("json", CreateItemSchema),
async (c) => respond(c, () => items.create(c.req.valid("json")), 201),
);
export default app;
Key patterns:
v("json", Schema)— validates request body/query/params against Zod schemarespond(c, result)— convertsResult<T>to proper HTTP response (200 for ok, 4xx/5xx for fail)describeRoute()+jsonResponse()— generates OpenAPI documentationauth.requireRole(...)— protects routes (seecloudskill for details)- Always export
type ApiType = typeof appfor the typed client
Contracts (Zod Schemas)
// contracts.ts
import { z } from "zod";
export { PaginationQuerySchema, PaginationResponseSchema, ErrorResponseSchema, parsePagination, createPagination } from "@valentinkolb/cloud/contracts";
export const ItemSchema = z.object({
id: z.string().uuid(),
title: z.string(),
description: z.string().nullable(),
createdAt: z.string().datetime(),
});
export type Item = z.infer<typeof ItemSchema>;
export const CreateItemSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
});
export type CreateItem = z.infer<typeof CreateItemSchema>;
export const ItemListResponseSchema = z.object({
items: z.array(ItemSchema),
pagination: PaginationResponseSchema,
});
Typed API Client
// api/client.ts
import { api } from "@valentinkolb/cloud/browser";
import type { ApiType } from ".";
export const apiClient = api.create<ApiType>({ baseUrl: "/api/my-app" });
The base URL must match how routes are mounted: app.start() mounts API routes at /api, and the app mounts its sub-Hono at /my-app, so the full path is /api/my-app. Sub-routes inside apiRoutes (/widget/..., /admin/..., root for CRUD) are picked up by the typed client automatically — apiClient.widget.today.$get() resolves to /api/my-app/widget/today, no manual stitching.
Always use the typed client in frontend code. It provides full type inference for all endpoints.
Frontend
Page Routing (frontend/index.ts)
Pages are NOT auto-routed from directory structure. Each page file exports a pre-wrapped ssr<AuthContext>(...) handler array. The route mapping in frontend/index.ts simply spreads these:
// frontend/page.tsx — page exports ssr handler directly
import { ssr } from "../config";
import type { AuthContext } from "@valentinkolb/cloud/server";
export default ssr<AuthContext>(async (c) => {
const user = c.get("user");
// ... fetch data ...
return () => <Layout c={c} title="My Page">...</Layout>;
});
// frontend/index.ts — maps routes to pages
import { Hono } from "hono";
import { auth, type AuthContext } from "@valentinkolb/cloud/server";
import mainPage from "./page";
import detailPage from "./[id]/page";
import adminPage from "./admin";
export const adminPages = new Hono<AuthContext>()
.get("/", auth.requireRole("admin", auth.redirectToLogin), ...adminPage);
export default new Hono<AuthContext>()
.get("/", auth.requireRole("user", auth.redirectToLogin), ...mainPage)
.get("/:id", auth.requireRole("user", auth.redirectToLogin), ...detailPage);
Auth middleware is applied per-route. The ssr function from config.ts wraps the page into a Hono middleware array that you spread into route definitions.
SSR Pages
Pages are server-rendered async functions that return JSX.
Important: SSR pages call services directly on the server (no API round-trip). This is one reason for the service pattern — services are shared between API routes and SSR pages. But be careful: API routes have explicit access checks (auth middleware, permission checks). If an SSR page calls a service without the same checks, the user could see data they shouldn't. Always verify permissions in SSR pages too, not just in API routes.
// frontend/page.tsx
import type { AuthContext } from "@valentinkolb/cloud/server";
import { Layout } from "@valentinkolb/cloud/ssr";
import { Pagination } from "@valentinkolb/cloud/ui";
import { parsePagination, createPagination } from "@valentinkolb/cloud/contracts";
import { items } from "../service";
import ItemList from "./_components/ItemList.island";
export default async (c: { get: (key: string) => any; req: any }) => {
const user = c.get("user");
const url = new URL(c.req.url);
const pagination = parsePagination({
page: Number(url.searchParams.get("page") ?? 1),
});
const { items: data, total } = await items.list(pagination);
const paginationResult = createPagination(pagination, total);
return () => (
<Layout c={c} title={[{ title: "Start", href: "/" }, { title: "My App" }]}>
<div class="max-w-4xl mx-auto">
<h1 class="text-xl font-semibold mb-4">My Items</h1>
<ItemList items={data} />
<Pagination
currentPage={paginationResult.page}
totalPages={paginationResult.total_pages}
baseUrl="/app/my-app?page="
/>
</div>
</Layout>
);
};
Key points:
- Pages are async functions that receive the Hono context
- Fetch data server-side using services directly (no API calls needed on the server)
- Return a render function
() => JSX(not JSX directly) - Use
Layoutfrom@valentinkolb/cloud/ssras the outermost wrapper titleprop accepts breadcrumbs:[{ title: "Parent", href: "/parent" }, { title: "Current" }]
Islands (Client Components)
Islands are interactive components that hydrate on the client:
// frontend/_components/ItemList.island.tsx
import { createSignal, For } from "solid-js";
import { mutation } from "@valentinkolb/stdlib/solid";
import { prompts } from "@valentinkolb/cloud/ui";
import { apiClient } from "../../api/client";
export default function ItemList(props: { items: Item[] }) {
const [items, setItems] = createSignal(props.items);
const deleteItem = mutation.create({
mutation: async (id: string) => {
const res = await apiClient.items[":id"].$delete({ param: { id } });
if (!res.ok) throw new Error((await res.json()).message);
},
onSuccess: (_, id) => {
setItems((prev) => prev.filter((i) => i.id !== id));
},
onError: (err) => prompts.error(err.message),
});
const createItem = mutation.create({
mutation: async () => {
const result = await prompts.form({
title: "New Item",
icon: "ti ti-plus",
fields: {
title: { type: "text", label: "Title", required: true },
description: { type: "text", label: "Description", multiline: true },
},
});
if (!result) return null; // user cancelled
const res = await apiClient.items.$post({ json: result });
if (!res.ok) throw new Error((await res.json()).message);
return res.json();
},
onSuccess: (created) => {
if (created) setItems((prev) => [created, ...prev]);
},
onError: (err) => prompts.error(err.message),
});
return (
<div class="flex flex-col gap-2">
<div class="flex justify-end">
<button
class="btn-primary btn-sm"
disabled={createItem.loading()}
onClick={() => createItem.mutate()}
>
{createItem.loading()
? <><i class="ti ti-loader-2 animate-spin" /> Creating...</>
: <><i class="ti ti-plus" /> New Item</>}
</button>
</div>
<For each={items()}>
{(item) => (
<div class="paper p-3 flex items-center justify-between">
<span class="text-sm font-medium">{item.title}</span>
<button
class="btn-danger btn-sm"
disabled={deleteItem.loading()}
onClick={() => deleteItem.mutate(item.id)}
>
<i class="ti ti-trash" />
</button>
</div>
)}
</For>
</div>
);
}
Key conventions:
- File must end in
.island.tsx— this is how the SSR framework detects islands (there is NO"use client"directive, that's Next.js) - ALL network calls must be inside
mutation.create()— never do manual fetch calls outside mutations mutationhandles loading/error state automatically — never create manual loading/error signalsprompts.form()goes INSIDE the mutation (it can also fail)- Use
mutation.loading()to show loading state and disable buttons - Use the typed
apiClient— never construct fetch calls manually - Props are serialized from server → immutable initial data
The Mutation + Prompts Pattern
This is the central UX pattern for all user actions. Everything goes in the mutation — including the prompt:
import { mutation } from "@valentinkolb/stdlib/solid";
import { prompts } from "@valentinkolb/cloud/ui";
const saveThing = mutation.create({
mutation: async () => {
// 1. Collect input (inside the mutation — prompts.form can fail too)
const data = await prompts.form({ title: "Save", fields: { ... } });
if (!data) return null; // user cancelled
// 2. Make the API call
const res = await apiClient.things.$post({ json: data });
if (!res.ok) throw new Error((await res.json()).message);
return res.json();
},
onSuccess: (result) => { if (result) { /* update local state */ } },
onError: (err) => prompts.error(err.message),
});
// 3. Wire to button with loading state
<button
class="btn-primary btn-sm"
disabled={saveThing.loading()}
onClick={() => saveThing.mutate()}
>
{saveThing.loading() ? "Saving..." : "Save"}
</button>
Layout Patterns
The platform provides several layout conventions. Use fullWidth on Layout for multi-column layouts.
Table Layout (like Logging app)
<Layout c={c} title="Admin" fullWidth> {/* or use AdminLayout for admin pages */}
<div class="flex flex-col gap-2">
<div>
<h1 class="text-base font-semibold text-primary">Items</h1>
<p class="mt-1 text-xs text-dimmed">{total} entries</p>
</div>
<FilterBar filter={filter} />
<ItemTable items={items} />
<Pagination currentPage={p.page} totalPages={p.total_pages} baseUrl="/app/my-app?page=" />
</div>
</Layout>
Sidebar + Content Layout (like Files, Weather)
<Layout c={c} fullWidth title={breadcrumbs}>
<div class="app-cols h-full">
<MySidebar items={items} activeId={activeId} />
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex-1 min-h-0 overflow-y-auto">
{activeItem ? <Detail item={activeItem} /> : <EmptyState />}
</div>
</div>
</div>
</Layout>
The app-cols class creates a responsive sidebar + content grid (sidebar hidden on mobile with toggle).
Card Grid Layout (like Spaces)
<Layout c={c} title={breadcrumbs}>
<div class="max-w-4xl mx-auto">
<div class="p-6 mb-4 text-center">
<h1 class="text-xl font-semibold mb-1">My Items</h1>
<p class="text-sm text-dimmed">Description</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
{items.map((item) => (
<a href={`/app/my-app/${item.id}`}
class="paper p-4 flex items-center gap-4 hover:paper-highlighted transition-all no-underline"
style={`view-transition-name: card-${item.id}`}>
<div class="w-10 h-10 rounded-xl bg-zinc-100 flex items-center justify-center">
<i class="ti ti-star text-lg" />
</div>
<div class="flex-1 min-w-0">
<span class="text-sm font-semibold text-primary block truncate">{item.title}</span>
<p class="text-xs text-dimmed truncate">{item.description}</p>
</div>
<i class="ti ti-chevron-right text-dimmed" />
</a>
))}
</div>
</div>
</Layout>
UI Reference
Full component props and CSS class reference →
references/frontend.md
Essential CSS classes:
| Class | Purpose |
|---|---|
btn-primary, btn-secondary, btn-danger, btn-success, btn-simple |
Button variants |
btn-sm, btn-md |
Button sizes |
paper, paper-highlighted |
Card with border/shadow, hover state |
text-primary, text-secondary, text-dimmed, text-label |
Text colors |
app-cols |
Sidebar + content responsive grid |
info-block-info, info-block-warning, info-block-danger |
Banners |
section-label |
Small uppercase label for sections |
Essential components from @valentinkolb/cloud/ui:
| Component | Purpose |
|---|---|
prompts.form({ fields }) |
Form dialog with typed fields |
prompts.dialog(render, opts) |
Custom dialog |
prompts.error(message) |
Error dialog |
prompts.search(resolver, opts?) |
Search dialog with async results |
Pagination |
Page navigation with URL params |
FilterChip |
Multi-option filter dropdown |
EntitySearch |
User/group search autocomplete |
PermissionEditor |
Access control UI (grant/revoke via ResourceAccessAdapter) |
SidebarLayout / SidebarFromSpec |
Sidebar with nav, tree, controls |
DialogHeader |
Standard dialog header |
CopyButton |
Clipboard copy with feedback |
All input components are also from @valentinkolb/cloud/ui (not a sub-path):
TextInput, NumberInput, Select, TagsInput, ImageInput, DateTimeInput, ColorInput, PinInput, Checkbox, Switch, SegmentedControl, Slider
Note: Input components expect accessor functions for reactive props (e.g., value={() => mySignal()}, error={() => errors().name}).
URL State Management
Filters and pagination live in URL params (SSR-friendly).
In SSR pages — read directly from the Hono request URL:
const url = new URL(c.req.raw.url);
const search = url.searchParams.get("search") ?? "";
const page = Number(url.searchParams.get("page") ?? 1);
In islands — import the shared navigation helpers from @valentinkolb/cloud/ui:
import { navigateTo, refreshCurrentPath, currentPathWithQuery } from "@valentinkolb/cloud/ui";
navigateTo("/app/my-app/123"); // hard navigation, adds history entry
refreshCurrentPath(); // window.location.assign(currentPath) — full SSR re-render
refreshCurrentPath is window.location.assign(currentPathWithQuery()) — a full reload that re-runs SSR. It does not preserve scroll position or patch the DOM in place.
For search/filter bars — use SearchBar from @valentinkolb/cloud/ssr/islands:
import { SearchBar } from "@valentinkolb/cloud/ssr/islands";
// Automatically syncs search input to URL param and triggers navigation
<SearchBar action="/app/my-app" param="search" placeholder="Search items..." />
For complex filters — define typed filter builders per app:
// frontend/lib/filter.ts
export const buildFilterUrl = (base: string, updates: Partial<Filter>, current: Filter) => {
const url = new URL(base, window.location.origin);
// only include non-default values to keep URLs clean
if (updates.search ?? current.search) url.searchParams.set("search", updates.search ?? current.search);
url.searchParams.delete("page"); // reset pagination on filter change
return url.pathname + url.search;
};
View Transitions
View transitions are enabled globally via <meta name="view-transition" content="same-origin"> in the HTML template. Always add view-transition-name to elements that should animate between pages. This is not optional — use it on cards, headers, sidebars, tables, and any element that persists across page navigations.
// Static names for page sections
<div style="view-transition-name: admin-logs-title">...</div>
<section style="view-transition-name: admin-logs-table">...</section>
// Dynamic names for list items (enables card ↔ detail transitions)
<a href={`/app/my-app/${item.id}`} style={`view-transition-name: item-card-${item.id}`}>...</a>
Naming convention: {app}-{element}-{id?}. For sidebars with many items, use a vt() helper:
const vt = (key: string) => `contacts-sidebar-${key}`;
<div style={`view-transition-name:${vt(`book-${book.id}`)}`}>...</div>
Settings Integration
Declare per-app settings inside defineApp({ settings: { ... } }) as a typed map of dotted-key → definition. Reads happen either through the typed snapshot on c.get("settings") (sync, frozen for the request) or through the global getter from @valentinkolb/cloud/services/settings:
// config.ts — the single source of truth for app settings
import { defineApp } from "@valentinkolb/cloud";
export const app = defineApp({
id: "my-app",
// ...
settings: {
"my-app.feature_enabled": {
kind: "boolean",
label: "Enable feature X",
default: true,
description: "Whether feature X is active.",
},
},
});
// In a service or any non-request context:
import * as settings from "@valentinkolb/cloud/services/settings";
const enabled = await settings.get<boolean>("my-app.feature_enabled");
All settings.* reads are async — they go through the Redis cache-aside layer (5-minute TTL). Inside an HTTP handler prefer the sync per-request snapshot on c.get("settings") populated by middleware.settings(). Settings automatically appear in the admin settings UI, grouped by the dotted-key prefix.
Universal Search Integration
Add search to your app via capabilities in app.start(). See packages/weather/src/capabilities.ts for a real example:
capabilities: {
search: {
tags: ["items"],
help: "Search items by title",
run: async ({ query, limit, ctx }) => {
const rows = await sql`
SELECT id, title FROM my_app.items
WHERE LOWER(title) LIKE ${'%' + query.toLowerCase() + '%'}
LIMIT ${limit}
`;
return rows.map((r) => ({
id: r.id as string,
title: r.title as string,
href: `/app/my-app/${r.id}`,
icon: "ti ti-star",
}));
},
},
},
WebSockets
Hono v4+ supports WebSockets natively via Bun. Import the WebSocket adapter from hono/bun, register WS routes on your composed router, and spread the app.start() result with the websocket handler so Bun picks it up:
// In index.ts
import { websocket } from "hono/bun";
const result = await app.start({ fetch: router.fetch, openapi: apiRoutes });
export default { ...result, websocket };
See the notebooks app for a complete WebSocket implementation.
New App Checklist
- Create directory:
packages/my-app/withpackage.json,tsconfig.json,tsconfig.typecheck.json. - Create skeleton source files under
packages/my-app/src/:config.ts,index.ts,api/index.ts,api/client.ts,contracts.ts,migrate.ts,service/index.ts,styles/app.css,frontend/index.ts,frontend/page.tsx. - Add a service block in
compose.dev.yml(seecloud-opsskill). - Add a
COPY packages/my-app/package.json packages/my-app/line inDockerfile.devso the install layer caches the new workspace. bun installto refresh the lockfile, thenbun run dev:app my-app(orbun run dev:full). Migrations run on first startup; the gateway picks up the new app from Redis within ~5 s.
The standalone reference implementation lives at github.com/ValentinKolb/cloud-template — clone it as a starting point. Inside the monorepo, packages/weather (simple) or packages/contacts (permissions/detail panels) are the closest analogues.