Quickback Docs

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 compile

1. 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:8787

4. 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 dialog

This 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:5173

The 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, and status stays locked.
  • Errors inline. A guard or validation 400 shows under its field. A 403 shows the problem's detail.
  • Close only when open. The action's record precondition runs on the server. Closing a closed job shows the rejection in the dialog.
  • Role-aware. Delete and close both require owner or admin. A member therefore 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

On this page