Build an Ecommerce Store with Flare
A hands-on guide to building a real ecommerce store on Flare — my new full-stack framework — across both its stacks: Cloudflare (Workers, D1/Drizzle, R2) and Next.js (Vercel, Neon/Prisma). Descriptors that generate tables, APIs and an admin; business logic in hooks; resource policies; a server-priced checkout with stock control; digital-download delivery; an in-person till; and Stripe payments. Written against Flare 0.7.3.
Build an Ecommerce Store with Flare
A hands-on guide for developers new to Flare, covering both the Cloudflare and Next.js stacks
Written against: Flare
0.7.3(docs as of 29 September 2026) Time to complete: about 2 hours for the core store, plus payments You will build: a catalogue, an admin dashboard, a customer storefront (browse, basket, checkout, order history), digital-download delivery, and an optional in-person till.
Table of contents
- What you're building
- Pick your stack first
- Prerequisites and setup
- Five ideas that make Flare click
- The data model (both stacks)
- Business logic in hooks (both stacks)
- Permissions: lock this down before launch
- Checkout endpoints, downloads and the storefront
- Taking payments with Stripe
- Path A: Cloudflare stack, start to finish
- Path B: Next.js stack, start to finish
- Where the docs disagree, and what to verify
- Pitfall checklist
- Pre-launch checklist
- CLI cheat sheet
- Reference links
1. What you're building
Flare turns one description of a resource (a descriptor) into a database table, a migration, validators, REST routes, a typed client, policy hooks and four dashboard pages. You describe Product once and get a working admin for it.
That covers the back office. A store also needs things Flare does not generate, and this guide walks you through those:
| Piece | Who builds it |
|---|---|
| Product, Category, Customer, Order, OrderItem tables, APIs and admin screens | Flare generates |
| Order numbering, line totals, stock movement | You write as hooks on the resources |
| Checkout (server-side pricing, stock check, order creation) | You write with flare gen endpoint |
| Storefront pages, basket, customer order history | You write as ordinary Next.js pages |
| Payments | Stripe. flare gen billing covers subscriptions and single-item purchases, but not a multi-item basket (see section 9) |
The reference implementation is examples/shop in the framework repo (Cloudflare) and examples/next-shop (Next.js). Copy from them rather than retyping large UI components.
2. Pick your stack first
The stack is chosen once, at flare create, and cannot be changed afterwards. There is no migration command and none is planned. Moving a small app is roughly an afternoon (regenerate on a new app and move the data). Moving one with a year of migrations is not.
| Cloudflare (default) | Next.js | |
|---|---|---|
| Runs on | Cloudflare Workers (vinext) | Vercel (Next.js 16) |
| Database / ORM | D1 (SQLite) / Drizzle | Neon Postgres / Prisma 7 |
| Cache | Workers KV | Upstash Redis |
| Files | R2 | R2 (or UploadThing) |
| Realtime | Yes (Durable Objects) | No. realtimeChannel().publish() is a no-op |
| In-app traffic analytics | Yes | No, links to Vercel's dashboard |
| Fill a table from a descriptor | flare seed:resource | Not available. Use seed:make + seed |
| Cost | The cheaper one by a distance | Vercel, Neon and Upstash each bill separately |
Recommendation for a store:
- Start on Cloudflare if you're unsure. It's cheaper, has effectively no cold starts, and R2 charges nothing for egress, which matters for a store serving product images and downloads.
- Choose Next.js if you know you need Postgres features (ranked full-text search, window functions,
jsonb, materialised views) or Node-only npm packages. The Workers runtime is not Node. - The docs' own advice: pick for the database. Everything else is shared or a day's work to change.
What is identical on both stacks: descriptors, hooks, computed fields, policies, seeds, the field grammar, every dashboard component, and the REST API shape. What differs: the schema and migrations, the DB client, the cache adapter and the deploy.
Confirm which stack an existing project uses before touching anything:
node -p "require('./package.json').flare?.stack ?? 'cloudflare'"3. Prerequisites and setup
Both stacks: Node 20+, and pnpm if you have it (an app is ~340 packages; pnpm installs in seconds where npm takes minutes).
Cloudflare stack: a Cloudflare account. See Setting up Cloudflare.
Next.js stack: a Postgres connection string (a Neon free tier is what the stack is tuned for; any Postgres 12+ works, including Docker), and a Vercel account for deploying.
Create the app
# Cloudflare (default)
pnpm create flare-framework shop
# Next.js
pnpm create flare-framework shop -- --stack next --yesThe interactive form asks the stack first, then theme, sign-in methods and social providers. Flags answer those up front:
pnpm dlx flare create shop --stack next --theme mono --auth passkeys,2fa-app --auth-providers google,github --yes
Six themes exist (default, coral, amber, sky, mono, emerald), switchable later with npx flare theme <name>. Each also changes the sign-in screen layout. To change colours, edit the tokens in app/globals.css, not the components.
Install the AI agent skill (recommended if you use a coding assistant)
pnpm dlx skills add MUKE-coder/flare-framework@flare
The machine-readable docs index is at https://flare-docs.codetotech.com/llms.txt.
Check your version
pnpm dlx flare --version
pnpm add -D @flaredev/cli@latest # to upgrade
Use 0.7.3 or newer on the Next.js stack. Earlier releases had bugs that hit stores directly: cursor pagination failed on the second page of any date-sorted list (0.7.3), and image uploads silently stored nothing (0.7.1). If you started an app before those releases, run
npx flare diffthennpx flare update(see the cheat sheet).
4. Five ideas that make Flare click
1. The descriptor is the source of truth. Change the descriptor, never the generated schema. Editing db/schema/ or prisma/schema/resources.prisma directly works until the next gen resource reverts it.
2. Generated blocks are fenced. Anything between // generated:start and // generated:end is overwritten on regeneration. Put your code outside the markers, in the same file, and it survives. Hand-editing inside a block blocks regeneration until you pass --force.
3. Nothing is hidden. The engine behind every endpoint is copied into your app at lib/resource/ (about 800 lines: query parsing, validation, hooks, pagination, the store, handlers, one row adapter per stack). When you wonder how pagination or an error code works, open the file. flare diff shows how your copy differs from the shipped one; flare update --yes takes the upstream version and discards your edits to those files.
4. Business logic lives in hooks, not route handlers. Hooks run on every write path: REST, dashboard, CSV import, seeds and the till. A handler runs for one path.
5. Roles are checked in policies/<resource>.policy.ts and nowhere else. Never check a role in a hook or a page.
Rules that cause real damage when broken
| Rule | Why |
|---|---|
Wrap descriptors in clientResource() before passing to a client component | Descriptors carry hooks and computed (functions), which can't cross the server/client boundary. The page 500s. |
Never import a server module into a "use client" file (@/db, @/lib/db, cloudflare:workers, or anything reaching them) | The build fails with an error pointing nowhere near the cause. |
| Skeletons, not spinners | Every dashboard route ships a shaped loading.tsx. Match it. |
| Don't hand-write a REST endpoint for something a resource already exposes | Use flare gen endpoint <Resource> <name> for the rest. The resource is positional. There is no --resource flag. |
Don't commit .env or .dev.vars | Commit only .env.example and .dev.vars.example. |
5. The data model (both stacks)
Five resources cover a store that sells both boxed goods and downloads. The commands are identical on both stacks.
pnpm dlx 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!, kind:enum(stock,digital), price:float, stock:int?, description:text?, image:file:[image]:5mb?, downloadFile:file:[archive,pdf]?, 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!, customer:belongsTo(Customer)?, channel:enum(online,pos), status:enum(pending,paid,fulfilled,refunded), total:float, paidWith:enum(cash,card,mobile)?, note:text?'
npx flare gen resource OrderItem --group Sales --icon list \
--fields 'order:belongsTo(Order), product:belongsTo(Product), name:string, quantity:int, unitPrice:float'
Why the model looks like this
| Decision | Reason |
|---|---|
kind:enum(stock,digital) | One field carries the whole difference between boxed and downloadable goods. Everything else keys off it. |
stock:int? (optional) | A digital product has no shelf. It stays null. |
downloadFile:file:[archive,pdf]? | The file a digital product is. Optional because boxed goods have none. The dropzone only accepts the categories the descriptor names. |
OrderItem.name and unitPrice | Snapshots of the product at time of sale. It looks like duplication and isn't: next month's price change must not rewrite what someone paid last month. |
slug:string! on Product/Category | Gives the storefront readable URLs. |
price:float | Flare's default for money, shown with the dashboard's digit grouping. Don't invent a cents integer unless your team decides to. Round totals to 2 decimals when computing them. |
Customer is separate from the login account | The account is who signs in; the Customer is who the shop sells to. They're matched on email, and the Customer row is created on first purchase. A shop may have served that email over the counter long before they made an account. |
Field-grammar quick reference
name:string, email:email!, bio:text?, price:float, active:boolean,
status:enum(draft,published), avatar:file:[image]:5mb?,
category:belongsTo(Category)?, notes:hasMany(Note)? optional · ! unique · string formats: email url tel domain country color slug · file categories: image video audio pdf doc sheet archive any. Run npx flare gen resource --help when unsure.
Enum values must be identifiers. Don't put
+, spaces or punctuation in them. On Postgres they become real enum types. Usea_poswith a label:field.enum([...], { optionLabels: { a_pos: "A+" } }).
Screens you get for free
Each resource appears in the sidebar under its --group with a sortable, filterable table, a multi-step sheet form, search, CSV import/export, saved views, a record page with child tables, an audit log, and an OpenAPI reference at /api/reference.
Apply the schema (differs by stack, see sections 10 and 11), then start with npx flare dev (http://localhost:3000), sign up at /dashboard and make yourself admin:
pnpm dlx flare user:role you@example.com admin
6. Business logic in hooks (both stacks)
Put these in the descriptor files, outside the generated block. The three rules below are the heart of a Flare store.
6.1 Number the orders (beforeCreate on Order)
Customers shouldn't supply an order number; the shop issues it. beforeCreate runs before validation, so it can fill a required field the caller couldn't supply. What it returns is still validated.
Cloudflare (Drizzle), from the shop tutorial:
// resources/order.resource.ts, outside the generated block
import { count } from "drizzle-orm";
import { orders } from "@/db/schema";
hooks: {
/** Orders are numbered, not named: SHOP-000001, in the order taken. */
async beforeCreate(input, { db }) {
if (input.reference) return input;
const [row] = await db.select({ total: count() }).from(orders);
return { ...input, reference: `SHOP-${String((row?.total ?? 0) + 1).padStart(6, "0")}` };
},
},Next.js (Prisma): the hook is the same shape, but the database call changes:
async beforeCreate(input, { db }) {
if (input.reference) return input;
const total = await db.order.count();
return { ...input, reference: `SHOP-${String(total + 1).padStart(6, "0")}` };
},Concurrency caveat (my note, not from the docs): count-based numbering can collide when two orders land at once.
referenceis!(unique), so the database rejects the duplicate with a 409 rather than corrupting data, but that sale fails. If you expect concurrent checkouts, generate the reference differently (a database sequence on Postgres, or a timestamp-based value like the CRUD guide's`ORD-${Date.now()}`).
6.2 Line totals without a column (computed on OrderItem)
Storing quantity × unitPrice invites the two to disagree. Compute it:
computed: {
/** What this line costs. Worked out rather than stored, so it can't drift. */
lineTotal: (item) => Number(item.quantity) * Number(item.unitPrice),
},lineTotal now appears on every OrderItem the API and dashboard return. No column, no migration.
6.3 Stock that moves when you sell (afterCreate on OrderItem)
Selling a boxed product takes one off the shelf. A download doesn't. Putting this on the resource means it runs for every way a sale can happen: online checkout, the till, a CSV import, a seed.
Cloudflare (Drizzle):
import { eq, sql } from "drizzle-orm";
import { products } from "@/db/schema";
hooks: {
async afterCreate(item, { db }) {
const [product] = await db.select().from(products)
.where(eq(products.id, String(item.productId))).limit(1);
if (product?.kind !== "stock") return;
await db.update(products)
.set({ stock: sql`max(0, coalesce(${products.stock}, 0) - ${Number(item.quantity)})` })
.where(eq(products.id, String(item.productId)));
},
},Next.js (Postgres), same idea with a raw statement:
async afterCreate(item, { db }) {
const product = await db.product.findUnique({ where: { id: String(item.productId) } });
if (product?.kind !== "stock") return;
await db.$executeRaw`
UPDATE "products"
SET "stock" = GREATEST(0, COALESCE("stock", 0) - ${Number(item.quantity)})
WHERE "id" = ${String(item.productId)}
`;
},The decrement is one SQL statement, not read-then-subtract-then-write, so two tills selling the last box at the same instant can't both compute from "1 left".
Two honest limits. (1) The checkout endpoint checks stock before it writes, and the decrement clamps at zero, so stock never goes negative, but two simultaneous buyers can both be sold the last unit. If overselling is unacceptable, make the decrement conditional (
WHERE stock >= n) and fail the sale if no row changed. (2) The order and its lines are separate writes with no wrapping transaction, so a failure midway can leave a partial order. The tutorial mitigates this by validating the whole basket before writing anything.
Verify the hook signature in your app. The shop tutorial writes
afterCreate(item, { db }), while the CRUD guide writesafterUpdate: async ({ row, db }). Openlib/resource/store.tsto see exactly what your version passes (rule: read the code, don't guess).
6.4 Refunds put stock back (afterUpdate on Order)
Same shape as 6.3: when status becomes refunded, loop the order's lines and add quantities back for kind === "stock" products. Or scaffold a dedicated endpoint:
pnpm dlx flare gen endpoint Order refund --method POST --record --action update
This writes app/api/orders/[id]/refund/route.ts with the session check and policy check already in place.
7. Permissions: lock this down before launch
This is the section most likely to save you from a data leak.
The default is open
A resource with no policy file stays open to any signed-in user. Policies are opt-in per resource, not a default lockdown.
In a store, customers sign up too, so "any signed-in user" includes every shopper. Until you add policies, a customer who signs in could call GET /api/orders or /api/customers directly. The dashboard hides things by role; the API only refuses if a policy says so. Generate policies for every sensitive resource:
pnpm dlx flare gen policy Order --roles admin,staff --delete-roles admin
npx flare gen policy OrderItem --roles admin,staff --delete-roles admin
npx flare gen policy Customer --roles admin,staff --delete-roles admin
npx flare gen policy Product --roles admin,staff --delete-roles admin
npx flare gen policy Category --roles admin,staff --delete-roles admin
This writes files like policies/order.policy.ts with a fenced roles block:
export default definePolicy({
resource: "Order",
// generated:start hash=…
read: ["admin", "staff"],
create: ["admin", "staff"],
update: ["admin", "staff"],
delete: ["admin"],
// generated:end
});"*" in any array means any signed-in user. 401 means no session; 403 means signed in but not permitted. Add roles beyond admin and staff with flare role:add support --label "Customer support", then assign with flare user:role.
Prove it isn't UI-only. Call the API directly as a role that shouldn't have access:
curl -X PATCH https://YOUR-APP/api/orders/<id> \
-H "Cookie: <customer session cookie>" \
-H "Content-Type: application/json" -d '{"status":"paid"}'
# → must be 403Customers see their own orders through your code, not through policies
The roles-and-policies guide says the model is resource-level only, with no per-record ownership: a role can read a whole resource or it can't. So don't hand customers read access to Order. Instead, follow the shop tutorial: the storefront reads through your own lib/store.ts, server-side, with queries that filter by the signed-in customer. Each query says out loud what it will show:
// lib/store.ts (customer's view; deliberately NOT the generated dashboard store)
export async function listProducts(categoryId?: string) {
const where = categoryId
? and(eq(products.active, true), eq(products.categoryId, categoryId))
: eq(products.active, true);
return getDb()
.select()
.from(products)
.where(where)
.orderBy(desc(products.createdAt))
.limit(60);
}(On Next.js, write the equivalent with your app's Prisma client. See how a generated route imports it: prismaRows(prisma.product, prisma).)
For order history, always filter by the customer resolved from the session account, never from an id in the URL alone.
8. Checkout endpoints, downloads and the storefront
Two sales, two endpoints, one shared rule. Both end up as the same Order through the same hooks.
| Till (in-person) | Online checkout | |
|---|---|---|
| Who | Staff at the counter | A signed-in customer |
| Auth | authorize({ resource: orderResource, action: "create" }) | currentAccount() → 401 if none |
| Customer | Usually none | Attached via customerForAccount(account, true) |
channel | pos | online |
The golden rule: the browser sends ids and quantities, never money
// The basket, the entire shape:
export interface CartLine {
productId: string;
quantity: number;
}Prices are looked up from the database on the server at checkout, so a basket edited in devtools can't buy anything at the wrong price.
Scaffold the endpoint
pnpm dlx flare gen endpoint Order checkout --method POST
Creates app/api/orders/checkout/route.ts with the authorization and store wired. The file is yours; regeneration leaves it alone. The core logic, condensed from the shop tutorial:
export async function POST(request: Request) {
const denied = await authorize({
request,
resource: orderResource,
action: "create",
});
if (denied) return denied;
const body = await request.json();
const lines = (body.lines ?? []).filter(
(l) => l.productId && Number(l.quantity) > 0
);
if (lines.length === 0)
return Response.json(
{ error: "There's nothing in the basket." },
{ status: 400 }
);
// 1. Load real products and price EVERYTHING before writing ANYTHING.
const priced = [];
for (const line of lines) {
const product = byId.get(line.productId);
if (!product)
return Response.json(
{ error: "One of those products no longer exists." },
{ status: 400 }
);
if (!product.active)
return Response.json(
{ error: `${product.name} isn't for sale.` },
{ status: 400 }
);
const quantity = Math.floor(Number(line.quantity));
if (product.kind === "stock" && (product.stock ?? 0) < quantity)
return Response.json(
{ error: `Only ${product.stock ?? 0} of ${product.name} left.` },
{ status: 409 }
);
priced.push({ product, quantity });
}
// 2. Total from database prices, rounded to 2dp.
const total = priced.reduce(
(sum, l) => sum + l.product.price * l.quantity,
0
);
// 3. Create the order THROUGH THE STORE so hooks run (numbering etc.).
const order = await dashboardStore("Order").create({
channel: "pos",
status: "paid",
paidWith: body.paidWith ?? "cash",
total: Math.round(total * 100) / 100,
});
if (!order.ok)
return Response.json({ error: order.error }, { status: order.status });
// 4. One OrderItem per line, THROUGH THE STORE, so the stock hook fires.
const items = dashboardStore("OrderItem");
for (const { product, quantity } of priced) {
await items.create({
orderId: String(order.data.id),
productId: product.id,
name: product.name,
quantity,
unitPrice: product.price,
});
}
return Response.json({
ok: true,
order: { reference: order.data.reference, total: order.data.total },
});
}Notice both writes go through dashboardStore(...), not raw SQL. That is what makes the numbering and stock hooks run. It is the payoff of putting logic on the resource.
The online version is a second endpoint that checks currentAccount(), prices the basket identically, creates the order with channel: "online" and customerId, then adds lines through the store.
Match account to Customer by email, creating the record on first purchase:
export async function customerForAccount(
account: { email: string; name: string },
create = false
) {
const [existing] = await db
.select()
.from(customers)
.where(eq(customers.email, account.email))
.limit(1);
if (existing || !create) return existing ?? null;
const [made] = await db
.insert(customers)
.values({ name: account.name || account.email, email: account.email })
.returning();
return made ?? null;
}Selling downloads
A digital product is only sold if the buyer gets the file. Files stay private in R2; nobody can reach one except through a signed link your app issues for a paid sale.
const DOWNLOAD_HOURS = 24;
const downloads = await Promise.all(
priced
.filter(({ product }) => product.kind === "digital" && product.downloadFile)
.map(async ({ product }) => ({
name: product.name,
url: await storage.createReadUrl({
key: product.downloadFile as string,
expiresIn: DOWNLOAD_HOURS * 3600,
downloadAs:
`${product.sku} ${product.name}`.replace(/[^\w .-]/g, "") + ".zip",
}),
}))
);Sign links at render time, never store them. On the customer's order page, sign fresh links each time the page renders. A link that expires can always be replaced by reopening the page; a link saved in the database is a permanent key to a private file. To attach a file to a product, open it at /dashboard/products and drop the file on Download file. Uploaded files don't travel with database rows, so on a new deployment you must re-upload them.
The storefront pages
All ordinary server components reading through lib/store.ts:
| Page | Shows |
|---|---|
/ | Hero, categories, latest products |
/products, /categories/[slug] | Grids of what's for sale |
/products/[id] | One product with Add to basket |
/cart | The basket, priced by the server |
/account/orders, /account/orders/[id] | What this person bought, with download buttons |
Keep the basket in localStorage via a React context (copy components/store/cart.tsx from the repo). Read it after mount in a useEffect, not during render. The server has no localStorage, and a count it can't know would make first paint disagree with the sent markup. Mount the provider in app/layout.tsx so the header count and basket page share one source. Sign-in accepts ?next=, so /sign-in?next=/cart returns shoppers to their (still-stored) basket.
Add your own pages to the admin sidebar in lib/dashboard-nav.ts, outside the generated block:
export const dashboardLinks: DashboardLink[] = [
{ label: "Till", href: "/dashboard/pos", icon: "shopping-cart" },
...generatedDashboardLinks,
{ label: "API reference", href: "/api/reference", icon: "book" },
];Remember rule 3. Any descriptor passed into a client component must go through clientResource().
9. Taking payments with Stripe
The tutorials mark orders paid the moment they're placed. That is fine for cash at a till and not acceptable online. Here is what Flare gives you and where its edge is.
What flare gen billing does
pnpm dlx flare gen billing --provider stripe --mode subscriptions
It generates Plan, Customer and Purchase resources with policies, lib/stripe.ts, checkout / portal / webhook routes under app/api/, and a /dashboard/billing page. Payment pages are Stripe-hosted (Checkout and Customer Portal), so no card data touches your app. Setup, in order:
- Put a Stripe test key in
.dev.varsasSTRIPE_SECRET_KEY(prefer a restrictedrk_test_…key). Never commit it. npx flare migrate(add--remotefor production).- In Stripe, create one Product per plan, each with prices (monthly, yearly, one-time), and give each Product the metadata
flare_app=<your app's package name>. npx flare billing:sync-plans(--remotefor production).- Point a Stripe webhook at
https://<your app>/api/webhooks/stripewith the ten events listed in the billing guide. Webhooks are required, not optional. The webhook is the only way the app learns about renewals, failed payments and cancellations.
The webhook verifies Stripe's signature and re-fetches each subscription or session from Stripe rather than trusting the payload, so duplicate and out-of-order deliveries end at the same, current state. Subscription state changes only through that signed webhook, never through the REST API.
Where it stops, and what that means for a store
- Checkout takes a plan slug, one price. It's designed for subscriptions and single-item one-time purchases, not a multi-line basket.
flare gen billingdoes not generate a basket-to-Stripe flow. - It creates its own
Customerresource. If your app already has one (as this guide's does), Flare adds the billing fields to your existingCustomerfields block and keeps your own fields. A hand-edited block is refused unless you pass--force. Run it on a clean git state and read the diff. - The setup steps are Cloudflare-flavoured (
wrangler secret put,.dev.vars,localhost:8787). The billing docs don't describe the Next.js stack. On Vercel, setSTRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETas environment variables and test the generated routes yourself.
Recommended pattern for a basket checkout (outside Flare's generated scope)
This is guidance, not a documented Flare feature:
- Your online checkout endpoint prices the basket from the database (section 8) and creates the
Orderasstatus: "pending", with itsOrderItemlines. - It then creates a Stripe Checkout Session whose line items come from those server-side prices, with the order's
referencein the session metadata, and returns the Stripe-hosted URL. - The webhook (
checkout.session.completed, plusasync_payment_succeededfor delayed methods) flips the order topaid. Never mark paid from the browser's success redirect. - Decide when stock decrements. Because the hook in 6.3 fires when a line is created, pending orders that are never paid will hold stock. Either release it on
checkout.session.expired/failure, or move the decrement to a hook on the pending→paid transition.
Tax: Flare doesn't enable Stripe's
automatic_tax. If you charge customers in the US or EU, look at Stripe Tax before launch.
Receipts by email
lib/mail.ts is wired to Resend. Send a receipt from an afterCreate (or paid-transition) hook on Order. Without RESEND_API_KEY and MAIL_FROM, email is printed to the console on the Next.js stack instead of sent.
10. Path A: Cloudflare stack, start to finish
1. Create and run
pnpm create flare-framework shop && cd shop
# generate the five resources from section 5, then:
npx flare migrate
npx flare dev
npx flare user:role you@example.com adminflare migrate applies pending D1 migrations locally (shared state with flare dev). After later descriptor changes, write a migration with:
pnpm dlx flare gen migration add_shipping_to_orders --from-schema # diff current schema
npx flare gen migration backfill_something # or a blank one
npx flare migrate
flare migrate:rollback undoes the last migration (--steps <n>; --remote requires --yes).
2. Add the hooks from section 6 using the Drizzle variants.
3. Seed opening stock. An empty shop is hard to build against.
pnpm dlx flare seed:make catalogue
Edit seeds/catalogue.seed.ts with real names and believable prices (insert categories first, map slug → id, then products, with stock: null for digital ones), then:
pnpm dlx flare seed catalogue
For load-testing at size rather than stocking a shop, npx flare seed:resource Product 100k builds rows straight from the descriptor (Cloudflare only).
Seeds don't roll back. A seed that fails halfway leaves what it already wrote, and the next run fails on a unique constraint that hides the real error. Clear the table before re-running (
seed:resource --truncatehelps for generated rows).
4. Build checkout, till, basket and storefront (section 8). Copy components/pos/till.tsx and components/store/cart.tsx from examples/shop rather than retyping ~200 lines of JSX. After a till sale, router.refresh() re-renders server components so stock counts and the day's takings update without losing client state.
5. Add policies (section 7).
6. Test locally. Open /dashboard/pos, sell a stock item twice for cash, then verify:
/dashboard/ordersshowsSHOP-000001, paid, channelpos/dashboard/order-itemsshows the line with alineTotalyou never stored/dashboard/productsshows the stock decreased
Sell a digital product and confirm the download link works. Then buy as a customer at /: basket → sign in → order, and confirm the same order appears in the dashboard with channel online.
7. Deploy
pnpm dlx flare deploy
The first deploy provisions the D1 database, applies migrations, and generates a production BETTER_AUTH_SECRET. Sign up on the live URL, then:
pnpm dlx flare user:role you@example.com admin --remote
npx flare db:push categories products --yes # copy local rows to the deployed DB
db:push only ever writes to the remote and changes nothing locally (--truncate clears remote rows first). It's how reference data like your catalogue reaches production, since seeds run locally. Then upload each digital product's file through the live dashboard; files live in R2, not the database.
flare deploy flags: --skip-migrations, --skip-secrets, --skip-security, --env <name>, --preview, --yes.
Optional hardening: npx flare gen security adds a request guard, a SecurityEvent resource and dashboard, and rate-limit bindings (Security guide). Given that a store has a public checkout, this is worth reading.
Cloudflare-specific constraints to remember
- Workers isn't Node. Packages that need the filesystem, native modules or long-lived TCP won't work.
- Free plan: 10 ms CPU per request; paid: 30 s. Fine for queries and renders, not for heavy processing.
- Don't bind a JS
Dateinto raw D1 SQL. D1 refuses object parameters. Pass milliseconds. - Search in tables is
LIKE-style across searchable string fields. Ranked full-text search is not a built-in Cloudflare-stack feature.
11. Path B: Next.js stack, start to finish
1. Create and configure
pnpm create flare-framework shop -- --stack next --yes
cd shop
cp .env.example .envDATABASE_URL="postgresql://user:pass@host/db?sslmode=require"
BETTER_AUTH_SECRET="..." # already generated for you
BETTER_AUTH_URL="http://localhost:3000"Add the four R2_* variables for file uploads. Without them the field renders and the upload fails, which is correct behaviour but confusing if you don't know why.
2. Generate resources (section 5), then understand the two schema files:
prisma/schema/
base.prisma # YOURS. Never regenerated. Generator, datasource, auth tables,
# audit_log, saved_views.
resources.prisma # GENERATED from descriptors. Overwritten on every gen resource.Two things that surprise people: enums become real Postgres enum types, and Flare adds the back-reference on the other side of every belongsTo (Prisma refuses one-sided relations). The back-reference is named after the model, so an order's lines are orderItems, not items. Check resources.prisma if unsure.
3. Apply the schema
pnpm dlx prisma migrate dev --name init # while building: diffs, writes SQL, applies
npx flare migrate # = prisma migrate deploy: applies what's already written
Use migrate dev on your machine; use flare migrate against anything that matters. It never invents a migration.
4. Hooks: use the Prisma variants in section 6.
5. Seed (no seed:resource on this stack):
pnpm dlx flare seed:make catalogue --resource Product # writes rows pre-filled from the descriptor
npx flare seed
db in a seed is your app's own Prisma client and insertMany is createMany with batching. Set updatedAt: new Date() explicitly, because @updatedAt is a Prisma-client behaviour that createMany skips. Wrap fake.date() in new Date(...) for date fields: SQLite accepts the date-only string, Postgres rejects it.
6. Make yourself admin, build the storefront and checkout (sections 7 and 8). Write lib/store.ts queries against the Prisma client.
7. Optional: ranked full-text search. This is the reason to choose this stack. It requires a migration Prisma's schema language cannot express:
pnpm dlx prisma migrate dev --create-only --name product_search_index
Fill the empty migration:
ALTER TABLE "products"
ADD COLUMN "search" tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce("name", '')), 'A') ||
setweight(to_tsvector('english', coalesce("description", '')), 'B')
) STORED;
CREATE INDEX "products_search_idx" ON "products" USING GIN ("search");pnpm dlx prisma migrate deploy
Query it safely (a tagged template binds ${cleaned} as a parameter; never use $queryRawUnsafe with concatenation):
return prisma.$queryRaw<Hit[]>`
select id, name, sku, price, kind::text as kind,
ts_rank("search", plainto_tsquery('english', ${cleaned})) as rank
from "products"
where "search" @@ plainto_tsquery('english', ${cleaned}) and "active" = true
order by rank desc, "created_at" desc
limit ${limit}`;The standing cost: Prisma can't see the
searchcolumn, soprisma migrate devwill offer to drop it (and its index) on every later migration toproducts. Never runmigrate devcasually on this table. Write migrations by hand and apply withmigrate deploy. If you'd rather not pay that cost, put the search column in a table of its own.
Measured on 302,000 products: a rare word ran in 0.26 ms via the index vs 2.07 ms with ILIKE, but a common word ran 44 ms via the index vs 1.08 ms with ILIKE, because ranking scores every match. At 2,000 rows, both were ~0.5 ms. Takeaway: below tens of thousands of rows, search isn't why your page is slow. Add the index when rare-term searches on a large catalogue start degrading.
8. Deploy to Vercel
pnpm dlx vercel link # once
npx flare deploy # = vercel deploy --prod (flags forwarded)
flare deploy here does nothing besides run the Vercel CLI. Migrations, secrets and domains belong to the Vercel project. The build script must stay prisma generate && next build.
Set on the Vercel project (not in a committed file):
| Variable | Notes |
|---|---|
DATABASE_URL | Neon's pooled string, not the direct one |
BETTER_AUTH_SECRET | 32+ random bytes. Generate a new one; don't reuse your laptop's |
BETTER_AUTH_URL | Must match the URL people actually visit, or cookies are set for the wrong origin and every session silently fails |
R2_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY | File uploads |
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN | Rate limits shared across instances |
RESEND_API_KEY, MAIL_FROM | |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | If taking payments (section 9) |
Migrations are yours to run, never part of the build. Vercel can build the same commit several times in parallel:
DATABASE_URL="postgres://…prod…" npx flare migrateRun migrations before the deploy when they add something the new code needs, and after when they drop something the old code still uses.
The docs don't describe loading a catalogue into production Postgres. Seeds run against whatever DATABASE_URL points at, so pointing it at production deliberately is the direct route. Confirm with npx flare seed --help, and remember seeds don't roll back.
Next.js-specific constraints to remember
- No realtime.
realtimeChannel().publish()silently does nothing. Don't build live order notifications on it. - Neon's free tier suspends idle databases after five minutes, so the first request afterwards is slow. That's a plan limit, and the fix is a paid tier with no scale-to-zero, not code.
- R2 is used for files (not Vercel Blob) because egress is free. Swapping to Blob means reimplementing
lib/storage.ts. - Don't use wrangler, D1 or Drizzle on this stack.
12. Where the docs disagree, and what to verify
The docs are unusually candid, but a few pages don't fully line up. Resolve these by reading your app's own lib/resource/ code before you build on them.
| Topic | What the pages say | What to do |
|---|---|---|
| Row-level policies | Roles & policies says policies are resource-level only, with no per-record ownership. The CRUD, end to end page shows a policy returning { customerId: session.userId } to filter rows. | Don't rely on row filtering. Test it in your version; until then, scope customer data in your own lib/store.ts queries (section 7). |
| Policy file shape | Roles & policies generates definePolicy({ read: [...] }); the CRUD page shows exported functions like export const read = (session) => .... | Look at what flare gen policy writes in your app and follow that. |
| Hook signature | Tutorial: afterCreate(item, { db }). CRUD page: afterUpdate: async ({ row, db }). | Read lib/resource/store.ts. |
| Hooks on Next.js | Hook examples in the tutorials are Drizzle. | The Prisma variants in this guide follow from the seed file's db.category... usage, but are not shown in the docs. Verify the db object your hooks receive. |
| Billing on Next.js | Setup instructions use wrangler/.dev.vars. | Treat as Cloudflare-documented; test on Next.js. |
| Customer collision | gen billing also owns a Customer resource. | Run on a clean git tree, review the merged fields, and use --force only knowingly. |
13. Pitfall checklist
- Edited inside
generated:start…end? It will be overwritten. Move code outside the markers. - Edited
db/schema/orresources.prismadirectly? Change the descriptor and regenerate. - Page 500s when passing a descriptor to a client component? Wrap in
clientResource(). - Build error that "points nowhere"? A server module is imported into a
"use client"file. - Role checks in a hook or page? Move them to
policies/<resource>.policy.ts. - No policy on a resource? Then any signed-in shopper can reach it through the API.
- Price accepted from the browser? Never. Send ids and quantities only.
- Writing orders with raw SQL? Go through
dashboardStore(...)or hooks won't run. - Storing signed download URLs? Sign at render time instead.
- Marking online orders paid on redirect? Only the verified webhook should.
-
prisma migrate devon a table with a generated column, GIN index or trigger? It will offer to drop them. Hand-write andmigrate deploy. - Enum value with
+or a space? Use identifiers plusoptionLabels. -
fake.date()into a Prisma date field? Wrap innew Date(). -
Datebound into raw D1 SQL? Pass milliseconds. - A hand-written endpoint duplicating a resource route? Use
flare gen endpoint <Resource> <name>. - Seed failed halfway, then unique-constraint errors? Clear the table and re-run.
-
.env/.dev.varscommitted? Remove and rotate. Commit only the.examplefiles. - Skipped
flare diff? Later releases don't arrive on their own, because Flare code is copied into your app. - Cross-origin front end? CORS is off by default and every write checks
Origin. Add explicit origins outside the generated block and inlib/resource/http.ts. Never reflect arbitrary origins with credentials.
14. Pre-launch checklist
Data and access
- Policies on
Order,OrderItem,Customer,Product,Category(andPlan/Purchaseif billing) - Verified with
curlas a customer session that admin-only endpoints return403 - First admin created on production (
user:role … --remoteon Cloudflare; run against prod DB on Next.js) - Catalogue loaded (
db:pushon Cloudflare) and digital files re-uploaded through the live dashboard
Money and stock
- Checkout prices from the database and rejects inactive/missing products
- Stripe webhook configured and verified with test cards; orders go
pending→paidonly via webhook - Decided stock behaviour for unpaid/expired orders and for refunds
- Tax handled (Stripe Tax if US/EU)
Platform
- Production
BETTER_AUTH_SECRETis new;BETTER_AUTH_URLmatches the live domain (Next.js) - Migrations applied to production deliberately (never from the build)
- Email sender configured (Resend) so receipts actually send
-
flare sync-types --checkpasses (exits 1 on drift; good for CI) - On Cloudflare: considered
flare gen security
Verify end to end (as three people)
- Admin: create product, upload image and download file
- Customer: browse, basket, sign in, pay, see order history, download file
- Attacker: edit basket prices in devtools; call
/api/ordersas a customer
15. CLI cheat sheet
| Task | Command |
|---|---|
| Create app | npx flare create shop [--stack next] [--theme mono] [--yes] |
| Switch theme | npx flare theme <name> |
| Generate resource | npx flare gen resource Name --group G --icon i --fields '…' (--force to overwrite a hand-edited block) |
| Generate custom endpoint | npx flare gen endpoint <Resource> <name> --method POST [--record] [--action update] |
| Generate policy | npx flare gen policy Name --roles admin,staff --delete-roles admin |
| Generate billing | npx flare gen billing --provider stripe --mode subscriptions |
| Sync Stripe plans | npx flare billing:sync-plans [--remote] |
| Generate security (Cloudflare) | npx flare gen security |
| Remove resource | npx flare rm resource Name |
| Add / assign role | npx flare role:add support · npx flare user:role me@x.com admin [--remote] |
| Migrate | Cloudflare: flare gen migration <n> --from-schema then flare migrate [--remote] · Next.js: prisma migrate dev then flare migrate |
| Roll back (Cloudflare) | npx flare migrate:rollback [--steps n] [--remote --yes] |
| Seed | flare seed:make <name> [--resource R] then flare seed [name] |
| Bulk sample rows (Cloudflare only) | flare seed:resource Product 25k [--truncate] [--seed N] |
| Push local rows to prod (Cloudflare) | flare db:push categories products --yes [--truncate] |
| Dev / build / start / deploy | flare dev · flare build · flare start · flare deploy |
| Share local app | flare dev --tunnel or flare tunnel 3000 |
| See drift from upstream | flare diff [filter] |
| Take upstream code | flare update [filter] (lists) → flare update --yes (applies; discards your edits) |
| Regenerate derived files / CI check | flare sync-types [--check] |
| Upgrade Flare | pnpm add -D @flaredev/cli@latest |
16. Reference links
Tutorials (start here)
- Cloudflare shop with till and downloads: https://flare-docs.codetotech.com/tutorials/shop/
- Next.js catalogue with Postgres search: https://flare-docs.codetotech.com/tutorials/next-shop/
- Working examples:
examples/shop,examples/next-shopin https://github.com/MUKE-coder/flare-framework
Decisions and concepts
- Choosing a stack: https://flare-docs.codetotech.com/start/stacks/
- Resource descriptor: https://flare-docs.codetotech.com/concepts/resource-descriptor/
- Field grammar: https://flare-docs.codetotech.com/concepts/field-grammar/
- Codegen overwrite contract: https://flare-docs.codetotech.com/concepts/codegen-contract/
- Nothing is hidden: https://flare-docs.codetotech.com/concepts/no-magic/
Guides
- CRUD, end to end: https://flare-docs.codetotech.com/guides/crud-example/
- API routes and handlers: https://flare-docs.codetotech.com/guides/api-routes/
- Roles & policies: https://flare-docs.codetotech.com/guides/roles-and-policies/
- Billing (Stripe): https://flare-docs.codetotech.com/guides/billing/
- File storage: https://flare-docs.codetotech.com/guides/storage/
- Migrations and seeds: https://flare-docs.codetotech.com/guides/migrations-and-seeds/
- Deploying to Cloudflare: https://flare-docs.codetotech.com/guides/deployment/
- Deploying to Vercel: https://flare-docs.codetotech.com/guides/vercel-deployment/
- What it costs: https://flare-docs.codetotech.com/guides/costs/
Reference
- CLI: https://flare-docs.codetotech.com/reference/cli/
- Changelog: https://flare-docs.codetotech.com/reference/changelog/
- Machine-readable index: https://flare-docs.codetotech.com/llms.txt
This guide is a companion to the official docs, not a replacement. Flare is moving fast (seven releases in the week before 0.7.3), so when this guide and your app's lib/resource/ code disagree, trust the code.


