Build a frontend
Put a React UI on your Quickback API — emit the typed client into a Vite app, add the shadcn components, sign in, and ship it on the same Worker.
This guide builds a React app that lists, creates, edits, and closes jobs against your API. It uses the components (shadcn registry items) on top of the emitted fetch client.
my-app/
├── quickback.config.ts
├── quickback/features/jobs/ # your API definitions
├── src/ # generated Worker (never edit)
└── web/ # your React app (you own it)
└── src/lib/
├── quickback.client.ts # emitted every compile
└── quickback.gen.ts # emitted every compile1. Scaffold the app
From the project root, create a Vite app with a shadcn base-ui style. The components don't support Radix styles.
npx shadcn@latest init -t vite -b base -p nova -n web
cd web && npm install better-auth && cd ..2. Emit the client into it
Add three keys to the scaffolded config and keep everything else:
// quickback.config.ts
import { defineConfig } from "@quickback/compiler";
export default defineConfig({
// …name, providers, etc. from the scaffold
types: { output: "web/src/lib/quickback.gen.ts" },
client: "web/src/lib/quickback.client.ts",
// The components read GET /api/v1/schema. Its default ("sysadmin")
// would 403 every other user; "member" admits organization members.
schemaRegistry: { access: "member" },
});The client imports its types from the first types output, so both files
live inside web/.
3. Define the API
// quickback/features/jobs/jobs.ts
import { feature, q } from "@quickback/compiler";
export default feature("jobs", {
columns: {
id: q.id(),
title: q.text().required(),
department: q.text().required(),
status: q.text().default("open").required(),
salaryMin: q.int().optional(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
guards: {
createable: ["title", "department", "salaryMin"],
updatable: ["title", "department", "salaryMin"],
protected: { status: ["close"] },
},
read: { access: { roles: ["owner", "admin", "member"] } },
create: { access: { roles: ["owner", "admin"] } },
update: { access: { roles: ["owner", "admin"] } },
delete: { access: { roles: ["owner", "admin"] }, mode: "soft" },
});
// quickback/features/jobs/actions/close.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction, jobs } from "../.quickback/define-action";
export default defineAction({
description: "Close the job to new applications",
input: z.object({ reason: z.string().optional() }),
access: { roles: ["owner", "admin"], record: { status: { equals: "open" } } },
async execute({ db, record }) {
const [row] = await db
.update(jobs)
.set({ status: "closed" })
.where(eq(jobs.id, record.id))
.returning();
return row;
},
});status is protected: the form shows it disabled, and only close can
change it.
quickback build # writes src/ and web/src/lib/quickback.{client,gen}.ts
npm run dev # API on http://localhost:87874. Add the components
In web/components.json, add the registry:
{
"registries": {
"@quickback": "https://docs.quickback.dev/r/{style}/{name}.json"
}
}cd web
npx shadcn add @quickback/resource-table @quickback/resource-form @quickback/action-dialog
npx shadcn add dialogThis installs quickback-provider too, since every item depends on it.
5. Sign in and render
// web/src/lib/api.ts
import { createAuthClient } from "better-auth/react"
import { organizationClient } from "better-auth/client/plugins"
import { createClient } from "@/lib/quickback.client"
const baseURL = import.meta.env.VITE_API_URL ?? "http://localhost:8787"
export const auth = createAuthClient({ baseURL, basePath: "/auth/v1", plugins: [organizationClient()] })
export const client = createClient({ baseUrl: baseURL })// web/src/App.tsx
import { useState } from "react"
import { auth, client } from "@/lib/api"
import { buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { QuickbackProvider } from "@/components/quickback-provider"
import { ResourceTable } from "@/components/resource-table"
import { ResourceForm } from "@/components/resource-form"
import { ActionDialog } from "@/components/action-dialog"
export default function App() {
const session = auth.useSession()
const member = auth.useActiveMember()
if (session.isPending) return null
if (!session.data) return <SignIn />
// Roles are a UI hint (masking, hiding Delete); the API stays authoritative.
return (
<QuickbackProvider client={client} roles={member.data ? [member.data.role] : undefined}>
<Jobs />
</QuickbackProvider>
)
}
function SignIn() {
const [error, setError] = useState<string>()
async function submit(form: FormData) {
const res = await auth.signIn.email({
email: String(form.get("email")),
password: String(form.get("password")),
})
if (res.error) return setError(res.error.message)
// Tables are firewalled to the active organization — pick one.
const orgs = await auth.organization.list()
const org = orgs.data?.[0] ?? (await auth.organization.create({ name: "Acme", slug: `acme-${Date.now()}` })).data
if (org) await auth.organization.setActive({ organizationId: org.id })
}
return (
<form action={submit} className="mx-auto flex max-w-sm flex-col gap-2 p-6">
<label>Email <input name="email" type="email" required /></label>
<label>Password <input name="password" type="password" required /></label>
<button className={buttonVariants()}>Sign in</button>
{error && <p role="alert">{error}</p>}
</form>
)
}
function Jobs() {
const [editing, setEditing] = useState<string | null>() // undefined closed, null create
const [closing, setClosing] = useState<string>()
const done = () => setEditing(undefined)
return (
<main className="mx-auto flex max-w-5xl flex-col gap-4 p-6">
<button className={buttonVariants()} onClick={() => setEditing(null)}>New job</button>
<ResourceTable
table="jobs"
onRowClick={(row) => setEditing(String(row.id))}
rowActions={(row) => (
<button className={buttonVariants({ variant: "outline", size: "sm" })} onClick={() => setClosing(String(row.id))}>
Close
</button>
)}
/>
<Dialog open={editing !== undefined} onOpenChange={(open) => !open && done()}>
<DialogContent>
<DialogHeader><DialogTitle>{editing ? "Edit job" : "New job"}</DialogTitle></DialogHeader>
{editing !== undefined && (
<ResourceForm table="jobs" id={editing ?? undefined} onSuccess={done} onCancel={done} onDeleted={done} />
)}
</DialogContent>
</Dialog>
<ActionDialog
table="jobs"
action="close"
id={closing}
open={closing !== undefined}
onOpenChange={(open) => !open && setClosing(undefined)}
/>
</main>
)
}Create an account first. Either POST /auth/v1/sign-up/email as in the
quickstart, or add a sign-up form with
auth.signUp.email. Then run the app:
npm run dev # http://localhost:5173The browser sends the Better Auth session cookie with every request.
http://localhost:5173 is a trusted origin by default until the project has
a production domain, so writes pass the CSRF check. See
Auth for the rules, and for bearer tokens in React
Native.
What you get for free:
- Only permitted fields. The form shows
guards.createable/updatable, andstatusstays locked. - Errors inline. A guard or validation
400shows under its field. A403shows the problem'sdetail. - Close only when open. The action's
recordprecondition runs on the server. Closing a closed job shows the rejection in the dialog. - Role-aware. Delete and
closeboth requireowneroradmin. Amembertherefore sees no Delete button, and the Close dialog shows a role message instead of its form. The API enforces the same rules either way.
6. Ship it on the same Worker
Serve the built app from the API's own Worker. It shares the API's origin, so it needs no CORS setup, and its hostname is trusted automatically.
cd web && VITE_API_URL=https://app.example.com npm run build && cd ..
rm -rf quickback/apps/web && cp -r web/dist quickback/apps/web// quickback.config.ts — add alongside the rest
apps: {
web: { domain: "app.example.com" },
},quickback build copies quickback/apps/web/ into the Worker's assets, then
quickback deploy ships both. See
App domains. To host the app elsewhere
instead, add its origin to trustedOrigins.
Next
- Components: every prop and hook
- Fetch client: typed calls beyond the components
- Query parameters: what
useListparams map to