Getting Started
This guide walks you through creating a CFast project and building a task manager that uses the core features: permission-aware database queries, multi-action routes, auto-generated forms, and the admin panel.
Prerequisites
Section titled “Prerequisites”- Node.js 20+
- pnpm (latest) —
npm install -g pnpm - A Cloudflare account — the free tier works
- Wrangler CLI —
npm install -g wrangler
Create Your Project
Section titled “Create Your Project”-
Scaffold the project
Terminal window npm create cfast@latest my-appWhen prompted, select all features (auth, permissions, db, ui, admin, email). The scaffolder generates a complete app with authentication, an admin panel, and an example
itemstable. -
Run migrations and start the dev server
Terminal window cd my-apppnpm installpnpm db:generatepnpm db:migrate:localpnpm devWrangler automatically creates a local D1 database from the binding in
wrangler.toml. Open http://localhost:8787 — you have a running app with authentication, an admin panel at/admin, and an exampleitemstable.
Build Your First Feature
Section titled “Build Your First Feature”Now let’s add a task manager. Each step introduces one CFast concept: schema with validation, permissions, actions, and auto forms.
1. Define the Schema
Section titled “1. Define the Schema”Open app/db/schema.ts and add a tasks table below the existing tables:
import { v } from "@cfast/forms";
// ... existing tables (users, sessions, items, etc.)
export const tasks = sqliteTable("tasks", { id: integer("id").primaryKey({ autoIncrement: true }), title: v(text("title").notNull(), { minLength: 3, maxLength: 200 }), status: text("status", { enum: ["todo", "in_progress", "done"] }) .notNull() .default("todo"), assigneeId: text("assignee_id") .notNull() .references(() => users.id), createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .$defaultFn(() => new Date()),});
export const tasksRelations = relations(tasks, ({ one }) => ({ assignee: one(users, { fields: [tasks.assigneeId], references: [users.id] }),}));Two things to notice:
v()wrapper — adds validation metadata (minLength,maxLength) that auto forms pick up automatically. The schema is the single source of truth for both database constraints and UI validation.text({ enum: [...] })— auto forms render this as a<select>dropdown instead of a text input. No manual field configuration needed.
Regenerate and apply the migration:
pnpm db:generatepnpm db:migrate:local2. Add Permissions
Section titled “2. Add Permissions”Open app/permissions.ts. The scaffolded file already defines admin and member roles, with admins granted manage all. Add grants for the member role:
import { definePermissions } from "@cfast/permissions";import { eq } from "drizzle-orm";import { tasks } from "~/db/schema";
export type UserRole = "admin" | "member";
export type AuthUser = { id: string; email: string; name: string; roles: UserRole[];};
const appRoles = ["member", "admin"] as const;
export const permissions = definePermissions<AuthUser>()({ roles: appRoles, hierarchy: { admin: ["member"], }, grants: (grant) => ({ member: [ grant("read", tasks), grant("create", tasks), grant("update", tasks, { where: (_cols, user) => eq(tasks.assigneeId, user.id), }), grant("delete", tasks, { where: (_cols, user) => eq(tasks.assigneeId, user.id), }), ], admin: [grant("manage", "all")], }),});
export function hasRole(user: AuthUser, role: UserRole): boolean { if (user.roles.includes("admin")) return true; return user.roles.includes(role);}
export function hasAnyRole(user: AuthUser, checkRoles: UserRole[]): boolean { return checkRoles.some((role) => hasRole(user, role));}This is the core of CFast’s permission model:
- Structural grants —
grant("read", tasks)lets members read all tasks. - Row-level filtering — the
wherecallback onupdateanddeleterestricts members to their own tasks. CFast injects this as a SQLWHEREclause automatically — no manual filtering in your queries. - Admins inherit everything —
grant("manage", "all")covers all CRUD on all tables. Thehierarchysetting means admins also inherit member grants.
These same permissions enforce on the server (database queries reject unauthorized operations) and reflect in the UI (action buttons hide when the user lacks permission).
3. Create Actions
Section titled “3. Create Actions”Create app/actions/tasks.ts:
import { redirect } from "react-router";import { compose } from "@cfast/db";import { eq } from "drizzle-orm";import { createAction } from "~/actions.server";import { tasks } from "~/db/schema";
export const createTask = createAction< { title: string; status: string }, Response>((db, input, ctx) => compose( [ db.insert(tasks).values({ title: input.title, status: input.status as "todo" | "in_progress" | "done", assigneeId: ctx.user.id, }), ], async (runInsert) => { await runInsert({}); return redirect("/tasks"); }, ),);
export const deleteTask = createAction<{ taskId: number }, Response>( (db, input) => compose( [db.delete(tasks).where(eq(tasks.id, input.taskId))], async (runDelete) => { await runDelete({}); return redirect("/tasks"); }, ),);Each createAction returns a lazy Operation — it describes what permissions are needed and what SQL will run, but doesn’t execute until the framework calls .run(). This means:
- The
db.insert(tasks)call checks the user’screategrant ontasksbefore executing. - The
db.delete(tasks)call checks the user’sdeletegrant — including the row-levelwherefilter. If the user isn’t the assignee, the operation is rejected with a 403. compose()groups multiple operations into a single operation with merged permissions. Each sub-operation still checks permissions independently when the executor calls it.
The createAction and composeActions functions come from the scaffolded app/actions.server.ts, which already binds the authenticated user, their grants, and the database to every action.
4. Build the Route
Section titled “4. Build the Route”Create app/routes/tasks.tsx:
import { desc } from "drizzle-orm";import { useActions } from "@cfast/actions/client";import { AutoForm } from "@cfast/forms/joy";import { ActionButton } from "@cfast/joy";import { composeActions } from "~/actions.server";import { app } from "~/cfast.server";import { tasks } from "~/db/schema";import { createTask, deleteTask } from "~/actions/tasks";import type { Route } from "./+types/tasks";
const composed = composeActions({ createTask, deleteTask });
export const action = composed.action;
export const loader = composed.loader(async ({ request, context }) => { const ctx = await app.context(request, context); const allTasks = await ctx.db.client .query(tasks) .findMany({ orderBy: desc(tasks.createdAt) }) .run({}); return { tasks: allTasks };});
export default function Tasks({ loaderData }: Route.ComponentProps) { const actions = useActions(composed.client);
return ( <div style={{ maxWidth: 600, margin: "0 auto", padding: "2rem" }}> <h1>Tasks</h1>
<AutoForm table={tasks} mode="create" exclude={["id", "assigneeId", "createdAt"]} onSubmit={(values) => { actions.createTask(values).submit(); }} />
<ul style={{ listStyle: "none", padding: 0, marginTop: "2rem" }}> {loaderData.tasks.map((task) => { const remove = actions.deleteTask({ taskId: task.id }); return ( <li key={task.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem 0", borderBottom: "1px solid #eee", }} > <span> <strong>{task.title}</strong> — {task.status} </span> <ActionButton action={remove} whenForbidden="hide" color="danger" variant="soft" size="sm"> Delete </ActionButton> </li> ); })} </ul> </div> );}Here’s what each piece does:
composeActionsmergescreateTaskanddeleteTaskinto a single route handler. The_actionfield in the form submission routes to the correct handler.composed.loaderwraps your loader to inject_actionPermissionsinto the response. This is what enablesuseActionson the client.ctx.db.client.query(tasks).findMany()is a permission-aware query — nounsafe()needed. The database automatically applies the user’sreadgrant. If the user had a row-levelwhereonread, only matching rows would be returned.AutoFormgenerates the form from thetasksschema. Thestatuscolumn (withenum) renders as a<select>dropdown. Thetitlecolumn picks up theminLength/maxLengthvalidation from thev()wrapper. Theexcludeprop hidesid,assigneeId, andcreatedAtsince those are set server-side.useActionsreads the permission metadata from the loader and returns action handles.ActionButtonis a permission-aware button from@cfast/ui. It handles submission, loading state, and confirmation dialogs. ThewhenForbidden="hide"prop tells it to hide entirely when the user lacks permission — when the current user isn’t the task’s assignee, the delete button disappears automatically.
5. Add to the Admin Panel
Section titled “5. Add to the Admin Panel”Open app/admin.server.ts and add tasks to the existing adminConfig object. Add it to the schema property so the admin panel can discover it, and optionally add dashboard widgets:
// In the existing adminConfig object, add tasks to the schema:schema: { items: schema.items, tasks: schema.tasks, // ← add this line},
// Optionally add dashboard widgets:dashboard: { widgets: [ // ... existing widgets { type: "count" as const, table: "tasks", label: "Total Tasks" }, { type: "recent" as const, table: "tasks", label: "Recent Tasks", limit: 5 }, ],},Navigate to /admin — your tasks table now appears with full CRUD, filtering, and role management.
What You Just Built
Section titled “What You Just Built”In about 80 lines of code across four files, you added a complete task manager with:
- Schema-driven validation — the
v()wrapper defines validation once, enforced in both the database and the auto-generated form. - Row-level permissions — members can only update and delete their own tasks. Admins can manage everything. Defined once, enforced automatically in every query and action.
- Permission-aware UI — delete buttons hide when the user lacks permission. No manual
ifchecks against the user’s role — the permission system tells the UI what to show. - Auto-generated forms — the
AutoFormcomponent reads your Drizzle schema and renders the right input types (text, select, number) with validation. No manual form fields. - Auto-generated admin — the admin panel discovers your table and provides full CRUD out of the box.
Next Steps
Section titled “Next Steps”- Tutorial: Build a Team Blog — Walk through building a complete app across 8 chapters covering auth, storage, and email.
- Package guides — Deep dives on each package:
- Permissions — Role hierarchy, row-level filtering, structural checks
- Actions — Multi-action routes, composed workflows, client hooks
- Forms — Field customization, external form control, custom plugins
- Database — Caching, pagination, transactions, composed operations
- Admin — Custom views, dashboard widgets, impersonation