CRUD, end to end: a shop
This is a real shop, built with the commands below and exercised with real requests. Every response on this page came out of the running app.
The resources
Section titled “The resources”npx flare gen resource Category --group Catalogue --icon tag \ --fields 'name:string!, slug:string!, image:file:[image]:5mb?, description:text?'
npx flare gen resource Product --group Catalogue --icon package \ --fields 'name:string, slug:string!, sku:string!, price:float, compareAt:float?, stock:int, image:file:[image]:5mb?, description:text?, tags:multiselect(new,sale,bestseller)?, category:belongsTo(Category)?, active:boolean'
npx flare gen resource Customer --group Sales --icon users \ --fields 'name:string, email:email!, phone:tel?, notes:text?'
npx flare gen resource Order --group Sales --icon receipt \ --fields 'reference:string!, status:enum(pending,paid,shipped,delivered,refunded), total:float, placedOn:datetime, customer:belongsTo(Customer)?, note:text?'
npx flare gen resource OrderItem --group Sales --icon list \ --fields 'quantity:int, unitPrice:float, order:belongsTo(Order), product:belongsTo(Product)?'
npx flare gen resource Review --group Catalogue --icon star \ --fields 'rating:int, title:string?, body:text?, product:belongsTo(Product), customer:belongsTo(Customer)?, approved:boolean'Then once:
npx prisma migrate dev --name init # or `flare migrate` on CloudflareThat is 24 endpoints, 24 dashboard pages, six typed clients and six sets of validators. Now what they actually do.
Read: a list
Section titled “Read: a list”curl "$API/products?perPage=3&sort=-price"{ "data": [ { "id": "aa259bd0", "name": "Oak desk lamp", "sku": "SKU-0037", "price": 387.19, "stock": 64, "tags": ["sale"], "active": true } ], "meta": { "page": 1, "perPage": 3, "total": 40, "exactTotal": true, "nextCursor": "WzM4Ny4xOSwiZDM5YmU5ZWQt" }}The SQL behind it:
SELECT id, name, slug, sku, price FROM "products"ORDER BY "products"."price" DESC, "products"."id" DESCLIMIT 4 OFFSET 0LIMIT 4 for perPage=3 — one row past the page is how it knows another page
exists without counting the table.
Read: searching and filtering
Section titled “Read: searching and filtering”curl "$API/products?q=lamp&filter[active]=true&perPage=2"curl "$API/orders?filter[status]=paid&sort=-placedOn"q searches every searchable string field of that resource:
WHERE ("products"."active" = $1 AND ("products"."name" ILIKE ('%'||$2||'%') OR "products"."slug" ILIKE ('%'||$3||'%') OR "products"."sku" ILIKE ('%'||$4||'%')))Read: paging a big catalogue
Section titled “Read: paging a big catalogue”Page numbers are fine to begin with. Past a few thousand rows, use the cursor the previous response handed you:
curl "$API/products?perPage=25&cursor=WzM4Ny4xOSwiZDM5YmU5ZWQt"WHERE ("created_at" < $1 OR ("created_at" = $2 AND "id" < $3))ORDER BY "created_at" DESC, "id" DESCLIMIT 26The (sort, id) pair is compared as one thing, so rows sharing a timestamp
are neither skipped nor repeated — the failure OFFSET has when rows are
inserted while someone is paging.
Create
Section titled “Create”curl -X POST "$API/products" \ -H 'Content-Type: application/json' -H "Origin: $ORIGIN" \ -d '{"name":"Brass kettle","slug":"brass-kettle","sku":"SKU-0041","price":48.5,"stock":12,"active":true}'HTTP/1.1 201 Createdlocation: /api/products/28d40784-7244-4cb3-b0c7-808754bad21fWhen it is wrong
Section titled “When it is wrong”{ "error": "Validation failed.", "issues": [{ "path": "price", "message": "Enter a number" }] }422, and no SQL ran — validation happens before the database is touched.
When it collides
Section titled “When it collides”{ "error": "Sku is already taken.", "field": "sku" }409, with field naming the column, so a form puts the message under the
right input rather than at the top of the page.
Update
Section titled “Update”PATCH changes what you send:
curl -X PATCH "$API/products/$ID" -H 'Content-Type: application/json' \ -H "Origin: $ORIGIN" -d '{"price":52.00,"stock":9}'PUT replaces everything, so an omitted required field is an error. Use it
when you mean “this is the whole record now”.
Delete
Section titled “Delete”curl -X DELETE "$API/products/$ID" -H "Origin: $ORIGIN" # 204, empty bodycurl "$API/products/$ID" # 404 afterwardsRelations
Section titled “Relations”OrderItem belongs to both Order and Product, so an order’s lines come
back with orderId and productId, and the dashboard renders a child table
on the order’s page.
Writing an order and its lines together is one statement:
await db.order.create({ data: { reference: "ORD-1042", status: "paid", total: 96.5, placedOn: new Date(), customerId: customer.id, updatedAt: new Date(), orderItems: { create: lines.map((line) => ({ ...line, updatedAt: new Date() })) }, },});Business rules belong on the resource
Section titled “Business rules belong on the resource”“An order’s total is the sum of its lines” should not live in a route handler, because the dashboard, a CSV import and a seed all write orders too. Put it in the descriptor, where every path runs it:
export default defineResource({ name: "Order", fields: { /* generated block */ },
hooks: { beforeCreate: (input) => ({ ...input, reference: input.reference ?? `ORD-${Date.now()}` }), afterUpdate: async ({ row, db }) => { if (row.status === "refunded") await restock(db, row.id); }, },
computed: { isOverdue: (row) => row.status === "pending" && Date.now() - Date.parse(String(row.placedOn)) > 7 * 86_400_000, },});computed values appear in API responses and on dashboard pages, and are
never stored.
Who may do what
Section titled “Who may do what”npx flare gen policy Product --roles admin,staff --delete-roles adminexport const read = () => true; // the shop is publicexport const create = (session) => ["admin", "staff"].includes(session.role ?? "");export const update = create;export const remove = (session) => session.role === "admin";Returning an object filters rows instead of refusing outright — how a customer sees only their own orders:
export const read = (session) => session.role === "admin" ? true : { customerId: session.userId };Both the API and the dashboard read this file, so the rule holds in both.
Something CRUD does not cover
Section titled “Something CRUD does not cover”npx flare gen endpoint Order refund --method POST --record --action updateWrites app/api/orders/[id]/refund/route.ts with the session and the policy
check already there and store ready to use. The rest is yours, and
regeneration leaves it alone.
What comes without asking
Section titled “What comes without asking”A sortable, filterable table per resource with CSV import and export and saved
views; a multi-step form in a sheet; a record page with its children; loading
skeletons shaped like each page; an audit log of every write; and an OpenAPI
document at /api/openapi.json, rendered at /api/reference.
The shop tutorial builds a storefront and a till on top of all this.