Skip to content
Flare 0.8.0: money, percent, rating, timezone, currency and seven more field types.See them all

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.

Terminal window
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:

Terminal window
npx prisma migrate dev --name init # or `flare migrate` on Cloudflare

That is 24 endpoints, 24 dashboard pages, six typed clients and six sets of validators. Now what they actually do.

Terminal window
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" DESC
LIMIT 4 OFFSET 0

LIMIT 4 for perPage=3 — one row past the page is how it knows another page exists without counting the table.

Terminal window
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||'%')))

Page numbers are fine to begin with. Past a few thousand rows, use the cursor the previous response handed you:

Terminal window
curl "$API/products?perPage=25&cursor=WzM4Ny4xOSwiZDM5YmU5ZWQt"
WHERE ("created_at" < $1 OR ("created_at" = $2 AND "id" < $3))
ORDER BY "created_at" DESC, "id" DESC
LIMIT 26

The (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.

Terminal window
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 Created
location: /api/products/28d40784-7244-4cb3-b0c7-808754bad21f
{ "error": "Validation failed.",
"issues": [{ "path": "price", "message": "Enter a number" }] }

422, and no SQL ran — validation happens before the database is touched.

{ "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.

PATCH changes what you send:

Terminal window
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”.

Terminal window
curl -X DELETE "$API/products/$ID" -H "Origin: $ORIGIN" # 204, empty body
curl "$API/products/$ID" # 404 afterwards

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() })) },
},
});

“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:

resources/order.resource.ts
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.

Terminal window
npx flare gen policy Product --roles admin,staff --delete-roles admin
policies/product.policy.ts
export const read = () => true; // the shop is public
export 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.

Terminal window
npx flare gen endpoint Order refund --method POST --record --action update

Writes 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.

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.