Common Recipes
Copy-paste patterns for auth, CRUD pagination, storage, realtime, and slow work
These recipes are intentionally small. They show the default Gencow path first, then point to deeper guides when a workflow needs more control.
Vite + React + Auth + API Client
Use one base URL for auth and API calls.
// src/lib/gencow.ts
import { createAuthClient, createGencowClient } from "@gencow/client";
import { api } from "../gencow/api";
export const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:5456";
export const auth = createAuthClient(baseUrl);
export const apiClient = createGencowClient({ api, baseUrl, auth });
export const { signIn, signUp, signOut } = auth;// src/main.tsx
import { GencowProvider } from "@gencow/react";
import { apiClient } from "./lib/gencow";
root.render(
<GencowProvider apiClient={apiClient}>
<App />
</GencowProvider>
);Login + CRUD List + Pagination
createCrud().list returns { data, total, nextCursor? }. The default list call should pass an explicit empty args object.
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@gencow/react";
import { api } from "../gencow/api";
const limit = 10;
export function TaskBoard() {
const [page, setPage] = useState(1);
const { data: result, isLoading, isFetching, error } = useQuery(api.tasks.list, { page, limit });
const { mutate: createTask, isPending } = useMutation(api.tasks.create);
const totalPages = useMemo(
() => Math.max(1, Math.ceil((result?.total ?? 0) / limit)),
[result?.total]
);
if (isLoading) return <p>Loading tasks...</p>;
if (error) return <button onClick={() => setPage(1)}>Retry</button>;
return (
<section>
<button disabled={isPending} onClick={() => createTask({ title: "New task" })}>
Add task
</button>
{result && result.data.length === 0 ? (
<p>No tasks yet.</p>
) : (
<ul>
{result?.data.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
)}
<nav>
<button onClick={() => setPage((value) => value - 1)} disabled={page <= 1 || isFetching}>
Previous
</button>
<span>
Page {page} of {totalPages}
</span>
<button onClick={() => setPage((value) => value + 1)} disabled={page >= totalPages || isFetching}>
Next
</button>
</nav>
</section>
);
}Storage Preview for Private Files
Store private files by default and return short-lived read grants for browser previews.
export const listFiles = procedure.query
.name("files.list")
.handler(async ({ context: ctx }) => {
const session = ctx.auth.requireAuth();
const rows = await ctx.db.select().from(files).where(eq(files.userId, session.user.id));
return Promise.all(
rows.map(async (file) => ({
id: file.id,
name: file.name,
previewUrl: (await ctx.storage.createReadGrant(file.storageId, {
ttlSeconds: 300,
disposition: "inline",
})).url,
}))
);
});Use public visibility only for intentionally public website assets:
const stored = await ctx.storage.store(file, { visibility: "public" });
const url = await ctx.storage.getPublicUrl(stored.id);AI Image - Storage
Generate an image through the Gencow AI helper, store it, and return a safe browser URL. Do not log prompts, provider keys, or private grant URLs.
import { ai } from "./ai";
import { procedure } from "./runtime";
export const generateCover = procedure.mutation
.name("covers.generate")
.handler(async ({ context: ctx }) => {
const session = ctx.auth.requireAuth();
const image = await ai.image.generate({
model: "gpt-image-2",
prompt: "A clean cover image for a task dashboard",
quality: "low",
});
const stored = await ctx.storage.storeBuffer(image.bytes, {
name: "cover.png",
type: "image/png",
visibility: "private",
metadata: { userId: session.user.id },
});
return {
storageId: stored.id,
previewUrl: (await ctx.storage.createReadGrant(stored.id, { ttlSeconds: 300 })).url,
};
});Realtime Chooser
| Use case | API | Why |
|---|---|---|
| Private/RLS list after a custom mutation | ctx.realtime.invalidate("tasks.list") |
Each client refetches with its own JWT and RLS scope |
| Public aggregate counter | ctx.realtime.refresh("stats.public") |
Server recomputes a safe public result |
| Public ticker/event | ctx.realtime.emit("ticker.feed", payload) |
Payload is safe for all subscribers |
| Private conversation event | await ctx.realtime.channel(conversationEvents, args).publish(event) |
Declared scope and subscriber authorization protect payload delivery |
For createCrud() write procedures, realtime invalidation is built in. create invalidates matching list buckets; update and remove also invalidate the exact get({ id }) subscription when get is generated.
Slow Work Chooser
| Work | Put it in | Notes |
|---|---|---|
| Fast validation and one DB write | procedure.mutation |
Keep the request short |
| Webhook or third-party callback | httpRoute / httpAction |
Validate input and secrets at the boundary |
| Multi-step resumable job | workflow builder | Use stable step names and idempotent side effects |
| Delayed one-off follow-up | ctx.scheduler.runAfter() |
BaaS uses Platform DB-backed durable scheduling |
| Fixed recurring work | cronJobs |
Deploy syncs the schedule into platform cron rows |
Local development keeps scheduler behavior best-effort and in-memory. Cloud deploy uses Platform DB-backed durable scheduling.
For a resumable import, checkpoint the stages and return a small summary:
// gencow/imports.ts
import { v } from "@gencow/core";
import { workflow } from "./runtime";
export const runImport = workflow
.name("imports.run")
.input(v.object({ sourceId: v.string() }))
.maxDuration("10m")
.handler(async (wf, { sourceId }) => {
const prepared = await wf.step("prepare", () => prepareImport(sourceId));
const saved = await wf.step("save", () => saveImport({
preparedId: prepared.id,
idempotencyKey: `${wf.workflowId}:save`,
}));
return { importId: saved.id, rowCount: saved.rowCount };
});prepareImport() and saveImport() are app-owned helpers. Persist large input
data in the preparation stage, and make the save helper honor its idempotency
key. Add runImport to defineApi({ workflows: { runImport } }), run
gencow codegen, then start api.imports.run and observe its returned run ID.
Advanced — completed steps versus repeated writes: A retry reuses saved checkpoint output. A crash after a write succeeds but before its checkpoint is saved can still repeat that write, so the database or external service must enforce the idempotency key. For more examples, see Workflow Agents and authorized realtime channels.