Skip to content

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.

  • Node.js 20+
  • pnpm (latest) — npm install -g pnpm
  • A Cloudflare account — the free tier works
  • Wrangler CLInpm install -g wrangler
  1. Scaffold the project

    Terminal window
    npm create cfast@latest my-app

    When prompted, select all features (auth, permissions, db, ui, admin, email). The scaffolder generates a complete app with authentication, an admin panel, and an example items table.

  2. Run migrations and start the dev server

    Terminal window
    cd my-app
    pnpm install
    pnpm db:generate
    pnpm db:migrate:local
    pnpm dev

    Wrangler 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 example items table.

Now let’s add a task manager. Each step introduces one CFast concept: schema with validation, permissions, actions, and auto forms.

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:

Terminal window
pnpm db:generate
pnpm db:migrate:local

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 grantsgrant("read", tasks) lets members read all tasks.
  • Row-level filtering — the where callback on update and delete restricts members to their own tasks. CFast injects this as a SQL WHERE clause automatically — no manual filtering in your queries.
  • Admins inherit everythinggrant("manage", "all") covers all CRUD on all tables. The hierarchy setting 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).

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’s create grant on tasks before executing.
  • The db.delete(tasks) call checks the user’s delete grant — including the row-level where filter. 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.

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:

  • composeActions merges createTask and deleteTask into a single route handler. The _action field in the form submission routes to the correct handler.
  • composed.loader wraps your loader to inject _actionPermissions into the response. This is what enables useActions on the client.
  • ctx.db.client.query(tasks).findMany() is a permission-aware query — no unsafe() needed. The database automatically applies the user’s read grant. If the user had a row-level where on read, only matching rows would be returned.
  • AutoForm generates the form from the tasks schema. The status column (with enum) renders as a <select> dropdown. The title column picks up the minLength/maxLength validation from the v() wrapper. The exclude prop hides id, assigneeId, and createdAt since those are set server-side.
  • useActions reads the permission metadata from the loader and returns action handles.
  • ActionButton is a permission-aware button from @cfast/ui. It handles submission, loading state, and confirmation dialogs. The whenForbidden="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.

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.

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 if checks against the user’s role — the permission system tells the UI what to show.
  • Auto-generated forms — the AutoForm component 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.
  • 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