FunFab — A Family 3D Print Shop, Built With AI in Two Weeks
Most of what I build ends up on a box in my study. FunFab is the first one that takes money from strangers.
It's a small family print farm in Singapore — fidget clickers shaped like dumplings and macarons, party favours by the dozen, name tags, and a custom queue where you drop an STL or a photo of a drawing and get a price back on WhatsApp. funfab.sg went from create-next-app to taking orders in fifteen days and thirty commits, built almost entirely with an AI assistant. This is the review: what it does, how it's put together, and the parts that were harder than they looked.
Disclosure: this was built and drafted with an AI assistant, then reviewed, run against the live shop, and edited by hand. Everything below describes what is actually deployed.
What it is
| Piece | What it does |
|---|---|
| Storefront | Catalogue, product pages, cart, custom-design upload, FAQ, how-it-works |
| Checkout | PayNow — order number as the payment reference, customer uploads a screenshot |
| Admin dashboard | Production queue, payment review, products, storefronts, legal pages, customers |
| WhatsApp channel | Incoming messages get an AI-drafted reply that a human approves |
| MCP server | The back office, exposed to Claude over per-admin OAuth |
Next.js 16, Supabase, Vercel, Tailwind v4 — the same stack as this site. The design started in Claude Design as wireframes and the repo was built straight from them, which meant the first real commit was a working catalogue, checkout and admin dashboard rather than a blank page.
Money is a state machine, not a form
The most important file in the shop is a map of which order states can follow which:
awaiting_payment → payment_review → paid → in_production → ready → shipped → completed
↘ cancelled (from any pre-shipped state)
The admin UI renders its buttons from that map, and the server action that changes status validates against the same map. One source of truth, two consumers — so a hand-crafted request can't jump an unpaid order straight to shipped, and every transition writes an append-only event with who did it.
PayNow has no webhook for a small merchant, so the flow is honest about that: you get the UEN and the order number as the reference, you upload your screenshot, and a person confirms it. I considered generating a per-amount SGQR code and deliberately didn't. It is easy to get subtly wrong, and a wrong QR is worse than no QR.
Four layers, each assuming the one above failed
The security model is the part I'd point another builder at:
→ Prices never come from the browser. The cart in localStorage holds product ids, colours and quantities. The server re-reads every price, the delivery fee and the free-delivery threshold, and recomputes the total. Edit the cart all you like — you change your screen, not your bill.
→ Grants before policies. The anonymous key can SELECT six catalogue tables and nothing else. Ask it for an order and Postgres refuses before row-level security is even consulted.
→ RLS on all fifteen tables, scoped to ownership or is_shop_admin(). Customers have no insert or update policy on orders at all — orders are written only by server actions, so tampering has nowhere to land.
→ requireAdmin() on every admin action. Server actions are just POST endpoints with stable ids; anyone who loaded the page once can replay one. So every action re-reads the session and re-checks the admin table before the service-role client is even built. Admin status lives in the database, never in a JWT claim, so there's no claim to forge.
The small ones matter too. Email lookups use .eq(), never .ilike() — an ilike value is a LIKE pattern, so [email protected] would match every six-letter mailbox at that domain. Customer STL uploads go straight to storage through a one-shot signed URL, so a 40 MB file never passes through a function, and admins download them with a forced download so a file claiming to be HTML is saved, not run.
The agent drafts. A human sends.
WhatsApp is where the customers actually are, so it's where the AI went — with a hard line around it. When a message comes in, Claude drafts a reply with a confidence level and, optionally, proposes back-office actions: mark this paid, add a note, quote this request. Proposals are stored as data. Nothing is sent and nothing touches an order until an admin approves it.
That was the whole design brief: an agent that makes the human faster without ever being the one who handles someone's money. If drafting fails, the webhook still succeeds and the admin just types the reply themselves. The AI is an accelerator, never a dependency.
Running the shop from Claude
The back office is also an MCP server. From a Claude conversation I can list today's orders, check a custom request, pull a sales summary, move an order along the state machine, flip a product out of stock, or create a new product and attach its photo. Writes are limited to reversible operations along the same allowed graph — prices, settings and deletes stay in the dashboard on purpose.
Each connection is authorised by a named admin through OAuth, shows up in the audit log as their email, and can be revoked on its own. There's a shared bearer token for break-glass use, and the README says to leave it unset. That's the same pattern as the agent: give the AI real tools, and make every one of them attributable and undoable.
The bugs that taught me something
→ An empty dashboard with no error. is_shop_admin() is called inside RLS policies, which run as the querying role. Revoke EXECUTE on it from authenticated and every admin policy fails closed — a correctly logged-in admin sees an empty dashboard and nothing anywhere says why. It's documented in the README now, in bold.
→ Seven hundred milliseconds of flight time. Vercel's default region is Virginia. A shopper in Singapore hit the Singapore edge, crossed to Virginia to run the function, then crossed to Sydney for each of the seven or eight database calls a page makes. Time to first byte was 0.6–1.1 s and almost all of it was the Pacific. One line in vercel.json pinned functions to sin1; moving the database to Singapore is the other half, scheduled as a proper restore window.
→ auth.uid() once per row. The Supabase advisor flagged that nine policies were re-evaluating auth.uid() for every row. Wrapping it as (select auth.uid()) makes Postgres evaluate it once per query. Same policy, one fewer way for a customer list to get slow.
→ Copy is code too. A commit titled "Drop the food-safety claims from the seeded product copy" is the one I'm quietly proudest of. The AI had written that the prints were food-safe. They're PLA made to order on a hobby farm; nobody had tested that. It came out before launch.
What's honestly not done
Order confirmation emails aren't sent yet — you get the on-screen page and a private link. Rate limiting is per serverless instance, which stops a noisy client, not a distributed flood. Stock isn't decremented, deliberately: with made-to-order printing, real stock is filament, and that lives in the ops dashboard. None of these block a family shop taking its first hundred orders; all of them are written down.
The review
FunFab is the clearest evidence I have for the thing this site is about. The assistant wrote most of the lines. What made it a shop instead of a demo was the set of decisions around the lines: prices are recomputed, not trusted; the agent drafts, it doesn't send; every AI tool is attributable and reversible; and a claim nobody tested gets deleted rather than shipped.
Go look at the dumpling clicker at funfab.sg. Or send it an STL and see how fast the quote comes back.
Enjoyed this post? Subscribe for more.
More from this category
Markdownify — Everything You Read or Hear, as Clean Markdown
A self-hosted pipeline that turns ebooks, audiobooks, and YouTube into clean Markdown — caption-first, transcribe only when it has to, and running on CPU so it never touches the GPU serving my models.
One DGX Spark, One Model, Room to Train
Run Qwen3.6-35B-A3B solo for inference on a single DGX Spark, and use the spare unified memory to train models alongside it.