JB logo
CoffeeyOUTUBE
Blog
Next

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

  1. What you're building
  2. Pick your stack first
  3. Prerequisites and setup
  4. Five ideas that make Flare click
  5. The data model (both stacks)
  6. Business logic in hooks (both stacks)
  7. Permissions: lock this down before launch
  8. Checkout endpoints, downloads and the storefront
  9. Taking payments with Stripe
  10. Path A: Cloudflare stack, start to finish
  11. Path B: Next.js stack, start to finish
  12. Where the docs disagree, and what to verify
  13. Pitfall checklist
  14. Pre-launch checklist
  15. CLI cheat sheet
  16. 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:

PieceWho builds it
Product, Category, Customer, Order, OrderItem tables, APIs and admin screensFlare generates
Order numbering, line totals, stock movementYou 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 historyYou write as ordinary Next.js pages
PaymentsStripe. 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 onCloudflare Workers (vinext)Vercel (Next.js 16)
Database / ORMD1 (SQLite) / DrizzleNeon Postgres / Prisma 7
CacheWorkers KVUpstash Redis
FilesR2R2 (or UploadThing)
RealtimeYes (Durable Objects)No. realtimeChannel().publish() is a no-op
In-app traffic analyticsYesNo, links to Vercel's dashboard
Fill a table from a descriptorflare seed:resourceNot available. Use seed:make + seed
CostThe cheaper one by a distanceVercel, 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 --yes

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

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 diff then npx 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

RuleWhy
Wrap descriptors in clientResource() before passing to a client componentDescriptors 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 spinnersEvery dashboard route ships a shaped loading.tsx. Match it.
Don't hand-write a REST endpoint for something a resource already exposesUse flare gen endpoint <Resource> <name> for the rest. The resource is positional. There is no --resource flag.
Don't commit .env or .dev.varsCommit 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

DecisionReason
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 unitPriceSnapshots 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/CategoryGives the storefront readable URLs.
price:floatFlare'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 accountThe 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. Use a_pos with 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. reference is ! (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 writes afterUpdate: async ({ row, db }). Open lib/resource/store.ts to 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 403

Customers 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
WhoStaff at the counterA signed-in customer
Authauthorize({ resource: orderResource, action: "create" })currentAccount() → 401 if none
CustomerUsually noneAttached via customerForAccount(account, true)
channelposonline

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:

PageShows
/Hero, categories, latest products
/products, /categories/[slug]Grids of what's for sale
/products/[id]One product with Add to basket
/cartThe 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:

  1. Put a Stripe test key in .dev.vars as STRIPE_SECRET_KEY (prefer a restricted rk_test_… key). Never commit it.
  2. npx flare migrate (add --remote for production).
  3. 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>.
  4. npx flare billing:sync-plans (--remote for production).
  5. Point a Stripe webhook at https://<your app>/api/webhooks/stripe with 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 billing does not generate a basket-to-Stripe flow.
  • It creates its own Customer resource. If your app already has one (as this guide's does), Flare adds the billing fields to your existing Customer fields 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, set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET as environment variables and test the generated routes yourself.

This is guidance, not a documented Flare feature:

  1. Your online checkout endpoint prices the basket from the database (section 8) and creates the Order as status: "pending", with its OrderItem lines.
  2. It then creates a Stripe Checkout Session whose line items come from those server-side prices, with the order's reference in the session metadata, and returns the Stripe-hosted URL.
  3. The webhook (checkout.session.completed, plus async_payment_succeeded for delayed methods) flips the order to paid. Never mark paid from the browser's success redirect.
  4. 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 admin

flare 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 --truncate helps 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/orders shows SHOP-000001, paid, channel pos
  • /dashboard/order-items shows the line with a lineTotal you never stored
  • /dashboard/products shows 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 Date into 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 .env
DATABASE_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 search column, so prisma migrate dev will offer to drop it (and its index) on every later migration to products. Never run migrate dev casually on this table. Write migrations by hand and apply with migrate 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):

VariableNotes
DATABASE_URLNeon's pooled string, not the direct one
BETTER_AUTH_SECRET32+ random bytes. Generate a new one; don't reuse your laptop's
BETTER_AUTH_URLMust 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_KEYFile uploads
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKENRate limits shared across instances
RESEND_API_KEY, MAIL_FROMEmail
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETIf 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 migrate

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

TopicWhat the pages sayWhat to do
Row-level policiesRoles & 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 shapeRoles & 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 signatureTutorial: afterCreate(item, { db }). CRUD page: afterUpdate: async ({ row, db }).Read lib/resource/store.ts.
Hooks on Next.jsHook 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.jsSetup instructions use wrangler/.dev.vars.Treat as Cloudflare-documented; test on Next.js.
Customer collisiongen 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/ or resources.prisma directly? 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 dev on a table with a generated column, GIN index or trigger? It will offer to drop them. Hand-write and migrate deploy.
  • Enum value with + or a space? Use identifiers plus optionLabels.
  • fake.date() into a Prisma date field? Wrap in new Date().
  • Date bound 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.vars committed? Remove and rotate. Commit only the .example files.
  • 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 in lib/resource/http.ts. Never reflect arbitrary origins with credentials.

14. Pre-launch checklist

Data and access

  • Policies on Order, OrderItem, Customer, Product, Category (and Plan/Purchase if billing)
  • Verified with curl as a customer session that admin-only endpoints return 403
  • First admin created on production (user:role … --remote on Cloudflare; run against prod DB on Next.js)
  • Catalogue loaded (db:push on 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 → paid only via webhook
  • Decided stock behaviour for unpaid/expired orders and for refunds
  • Tax handled (Stripe Tax if US/EU)

Platform

  • Production BETTER_AUTH_SECRET is new; BETTER_AUTH_URL matches 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 --check passes (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/orders as a customer

15. CLI cheat sheet

TaskCommand
Create appnpx flare create shop [--stack next] [--theme mono] [--yes]
Switch themenpx flare theme <name>
Generate resourcenpx flare gen resource Name --group G --icon i --fields '…' (--force to overwrite a hand-edited block)
Generate custom endpointnpx flare gen endpoint <Resource> <name> --method POST [--record] [--action update]
Generate policynpx flare gen policy Name --roles admin,staff --delete-roles admin
Generate billingnpx flare gen billing --provider stripe --mode subscriptions
Sync Stripe plansnpx flare billing:sync-plans [--remote]
Generate security (Cloudflare)npx flare gen security
Remove resourcenpx flare rm resource Name
Add / assign rolenpx flare role:add support · npx flare user:role me@x.com admin [--remote]
MigrateCloudflare: 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]
Seedflare 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 / deployflare dev · flare build · flare start · flare deploy
Share local appflare dev --tunnel or flare tunnel 3000
See drift from upstreamflare diff [filter]
Take upstream codeflare update [filter] (lists) → flare update --yes (applies; discards your edits)
Regenerate derived files / CI checkflare sync-types [--check]
Upgrade Flarepnpm add -D @flaredev/cli@latest

Tutorials (start here)

Decisions and concepts

Guides

Reference


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.