The Bakery Co.

A full-stack bakery ordering and business management platform — two Stripe payment flows, Claude MCP ordering, WebMCP browser-native AI tools, a loyalty rewards program, gift cards, subscription billing, collaborative filtering upsell, and a complete admin dashboard. Deployed on Render.


Live on Render Node.js Supabase Stripe MCP · WebMCP HTML · CSS · JavaScript Resend Twilio Gift Cards Subscriptions Loyalty Rewards
2 Stripe Payment Flows
4 Scheduled Cron Jobs
20 Pages
100 Lighthouse Accessibility
100 Lighthouse SEO
100 Lighthouse Best Practices

Demo

Site walkthrough — storefront, fuzzy search, cart, checkout, and admin dashboard.

Problem

Small bakeries take orders by phone or Instagram DM, lose track of them, and pay 15–30% commission to DoorDash or Uber Eats to get discovered online. There's no system, no paper trail, and no way to know which items are low on stock before the morning rush. The goal was to build them a direct ordering system they own — one that replaces $200–500/month in tools like Square Online or Toast, costs them zero commission per order, and gives the owner a real dashboard instead of a phone full of DMs.

Challenge-Based Learning

Challenge: Build a production-capable ordering platform from scratch — real payments, a relational database, authenticated admin routes, automated email/SMS, and scheduled background jobs — as someone still early in web development coming from iOS/Swift.

Approach: Used Node.js + Express for the backend, Supabase (Postgres) for the database with Auth and Storage bundled, Stripe Elements for payments with a payment-first flow so the order is only saved after the charge succeeds, Resend for transactional email, and Twilio for SMS. Deployed to Render.

Outcome: A forkable ordering platform that any food business could use — with a real checkout flow, a fully featured admin CMS, and automated operations. Replaces expensive SaaS tools and eliminates delivery platform commissions entirely.

Project Snapshot

  • Platform: Full-stack web app hosted on Render
  • Stack: HTML · CSS · JavaScript · Node.js · Express · Supabase · Stripe · Resend · Twilio · Chart.js · GTM/GA4
  • Type: Portfolio project built to production-level completeness
  • Team: Solo
  • Role: Full-stack developer — frontend, backend, database, payments, email/SMS, deployment
View Live Site

Tech Stack

HTML CSS JavaScript Node.js Express Supabase (Postgres) Stripe Elements Resend Twilio Chart.js Fuse.js Google Tag Manager GA4 Render

Technical Decisions

Why Supabase

Supabase bundles Postgres, Auth, and file Storage in one service. That meant no separate auth server, no third-party image host, and one set of credentials to manage. For a solo project, that consolidation is a meaningful reduction in complexity.

Payment-First Flow

The Stripe charge is confirmed via webhook before the order is written to the database. This prevents unpaid orders from ever entering the system — if the charge fails or the server crashes between payment and save, no phantom order appears in the admin queue.

node-cron Inside the Server

Scheduled jobs run inside the Express process via node-cron rather than a separate job scheduler. The tradeoff is simplicity vs. reliability — this works fine for a portfolio project, but in production a server restart would silently kill all scheduled jobs. A real deployment would use BullMQ or an external cron service.

Rate Limiting on Checkout

Rate limiting is applied specifically to the checkout endpoint, not just globally. A bad actor can spam most routes without consequence, but repeatedly hitting checkout could trigger Stripe fraud flags and put the merchant account at risk. That endpoint warranted its own stricter limit.

API-Level Auth vs. UI-Level Auth

The admin dashboard checks login state in the UI — but every API route it calls also verifies the JWT on the server. Hiding a button in the browser isn't security. Any HTTP client can call /api/admin/refund directly, so the server has to verify the token independently of what the frontend shows.

Forkable Architecture

The codebase is structured so the whole platform can be reskinned for any food business — different menu items, colors, and business name — in a few hours. The business logic (ordering, payments, admin) is the same regardless of what's being sold.

Key Features

Two Stripe Payment Flows

Standard checkout uses Stripe Elements embedded on the page — confirmCardPayment() client-side, order saved only after paymentIntent.status === "succeeded". The Claude/MCP flow uses stripe.checkout.sessions.create() server-side, returning a hosted Stripe URL. A single webhook endpoint handles both flows via metadata.source.

Claude MCP Ordering

Custom MCP server built with @modelcontextprotocol/sdk. Three tools: get_menu (live menu by category with availability and shippability flags), get_hours, and place_order (validates stock, builds Stripe line items, returns a hosted checkout URL). The tool description enforces a conversational ordering flow — name → email → fulfillment → address/time → tip → confirm.

WebMCP Browser Integration

Shipped the week Google announced it at I/O 2026. Registers menu search, store hours, and checkout form as browser-native AI tools via the W3C WebMCP standard (Chrome 146+). Gemini can interact with the storefront directly in the browser with no API credentials required — imperative tools via navigator.modelContext and declarative checkout attributes.

Collaborative Filtering Upsell

Checkout upsell uses a fallback chain: first tries real co-purchase frequency from the item_pairs table (populated on every order by iterating all item ID combinations). Falls back to manually curated paired_items. Falls back to time-aware cross-sell (Breakfast 6–11am, Savory 11am–2pm, etc.). Best sellers sorted first within each category, respects shippability context.

Crumb Rewards (Loyalty Points)

Customers earn Math.floor(total) points per order. Points are written via a Supabase RPC increment_points(uid, amount) to prevent race conditions from concurrent orders. Redeem: every 100 points = $1 off. Balance loaded at checkout with automatic token refresh on 401.

Gift Cards

Stripe webhook generates a unique 16-char hex code on purchase, inserts it to the database, and emails the recipient via Resend. At checkout, gift cards can cover partial or full order amounts. If the gift card covers everything, Stripe is skipped entirely — the order is saved with gc_${code} as the payment intent ID and no charge is made.

Subscription Billing

Stripe Checkout in mode: "subscription" with a one-off Price object per plan. Duplicate prevention checks the subscriptions table for an active matching email + plan before proceeding. invoice.payment_succeeded webhook events auto-generate recurring orders and email confirmations.

Admin Dashboard

Order management, revenue analytics with Chart.js, menu CMS with image uploads, inventory management, one-click refunds, coupon management, CSV export, and thermal receipt printing (80mm format) — all behind JWT + email-verified admin auth.

Coupon System

Server validates: active flag, expiry date, max uses, one_per_customer (checks past orders by email), and first_time_only (checks for any prior orders). Uses count increments on order save. Birthday cron runs nightly — finds matching birthdays, generates a unique BDAY-XXXXXXXX code (15% off, expires midnight), emails it, and records birthday_coupon_year to prevent duplicates.

Inventory Auto-Management

After every order, stock decrements per item. At 0, available flips to false automatically. Low-stock email fires below 5 units. Sold-out email fires at 0. The owner never has to manually toggle availability — the system does it.

Transactional Email & SMS

All emails use a shared branded template function — consistent dark-header HTML layout wrapping every transactional message (order confirmations, ready notifications, birthday coupons, low-stock alerts, gift card delivery, weekly reports). Twilio SMS for real-time order status so customers don't have to check their inbox.

Storefront & Cart

Live fuzzy search powered by Fuse.js with keyboard navigation. Cart persisted to localStorage. Dynamic OG tags on product pages — Express reads the HTML file, queries Supabase for the item's name/description/image, and injects <meta property="og:*"> tags before serving, so product links share correctly on social.

Product Thinking

Features that came from thinking about how the business owner actually uses this — not just what was technically interesting to build.

  • Scheduled pickup time slots with slot disabling — past times are disabled, same-day slots within 2 hours are removed, and orders placed after 6pm roll to next-morning slots. A customer placing an order at 8pm shouldn't be able to pick up at 9pm.
  • Minimum order enforcement before Stripe is called — the checkout button stays disabled until the cart meets the minimum. No partial charge attempts, no error messages from Stripe — the constraint is enforced in the UI before the payment flow starts.
  • Thermal receipt formatting (80mm) — the admin's print receipt is formatted for a standard 80mm thermal receipt printer, not a browser print dialog. That's the printer sitting next to every point-of-sale terminal. Getting this right meant the dashboard is actually usable in a real kitchen.
  • Local timezone date filtering — an early version filtered orders by UTC date. A 10pm order in Detroit showed up under the wrong day because UTC midnight had already passed. Fixed by converting all date comparisons to the server's local timezone so the admin's "today" matches their actual day.
  • Refund button with graceful error handling — if the Stripe refund API call fails, the owner sees a human-readable message telling them to go to the Stripe dashboard directly. Not a raw error. Not a spinner that hangs. A clear next step they can actually follow.
  • Gift card full-coverage Stripe skip — if a gift card covers the entire order total, Stripe is never called. The check is amountAfterGiftCard < 0.01. Trying to create a $0 Stripe PaymentIntent throws an error; this catches it before it ever gets there.
  • Birthday coupon deduplication — the cron records birthday_coupon_year on the customer profile after sending. The next morning's run checks that field before generating a new code, so customers get exactly one birthday coupon per year regardless of how many times the job runs.
  • Session token proactive refresh — at checkout initialization, if the Supabase session expires within 60 seconds, refreshSession() is called before the user ever hits an auth error. On a 401 from /api/points, the frontend retries with a refreshed token, then signs out if the refresh also fails — no stuck states.
  • Bidirectional paired items — when an admin edits a menu item's paired_items array, the server diffs old vs. new pairs and writes the reverse relationship on all affected items. Removing a pair also cleans up the reverse link. The recommendation graph stays consistent without the admin having to manually maintain both directions.

Challenges

Stripe Payment Flow — Payment-First, Then Save

The payment flow had more moving parts than a simple "charge card" integration. Tips are optional and percentage-based, delivery fees vary by zone, and orders below a minimum amount need to be blocked before Stripe is ever called. All of this is calculated server-side — trusting the client for cart totals is a security problem.

The key architectural decision was payment-first: the Stripe charge must succeed before the order is written to the database. The frontend creates a PaymentIntent on the server, completes the Stripe flow, and only then does the webhook confirm and save the order. If the server crashed between payment and save, that's exactly what Stripe webhooks are for — they retry delivery until the order is recorded. This prevents phantom orders in the admin queue and ensures no order exists in the system that hasn't been paid for.

STRIPE WEBHOOK — server.js
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

    try {
        event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
    } catch (err) {
        return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    if (event.type === 'payment_intent.succeeded') {
        const paymentIntent = event.data.object;
        const orderId = paymentIntent.metadata.order_id;

        await supabase
            .from('orders')
            .update({ status: 'paid', stripe_payment_id: paymentIntent.id })
            .eq('id', orderId);

        await sendOrderConfirmationEmail(orderId);
    }

    res.json({ received: true });
});

JWT Authentication on Admin Routes

The admin dashboard has real power — it can issue refunds, delete menu items, and export customer data. Every API route behind it needed to be protected so that only authenticated users could reach it.

Supabase Auth handles login and issues a JWT. On every admin API request, a middleware function verifies the token server-side before any data is read or written. An expired or missing token returns a 401 immediately.

AUTH MIDDLEWARE — middleware/auth.js
const requireAuth = async (req, res, next) => {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'No token provided' });
    }

    const token = authHeader.split(' ')[1];
    const { data: { user }, error } = await supabase.auth.getUser(token);

    if (error || !user) {
        return res.status(401).json({ error: 'Invalid or expired token' });
    }

    req.user = user;
    next();
};

// Applied to every admin route
app.use('/api/admin', requireAuth);

Scheduled Cron Jobs

The admin needed to receive a daily summary, a weekly revenue report, and alerts for orders that had been placed but not fulfilled — without having to remember to check anything manually. These needed to run on a schedule even when no one was using the app.

Four cron jobs run server-side using node-cron. Each queries Supabase for the relevant data, formats it, and sends via Resend. The unfulfilled order reminder only fires if there are actually open orders — no noise when the queue is clear. The birthday job records birthday_coupon_year after sending to ensure one coupon per customer per year regardless of how many times the job runs.

CRON JOBS — server.js
// Daily summary — 8pm every day
cron.schedule('0 20 * * *', sendDailySummary);

// Weekly report — Sunday 8pm
cron.schedule('0 20 * * 0', sendWeeklyReport);

// Unfulfilled order reminder — every 2 hours
cron.schedule('0 */2 * * *', sendUnfulfilledReminder);

// Birthday coupons — 8am every day
// Finds matching birthdays, generates unique BDAY-XXXXXXXX code (15% off),
// emails it, records birthday_coupon_year to prevent duplicates
cron.schedule('0 8 * * *', sendBirthdayCoupons);

Admin Refund Button — Idempotency and Graceful Failure

The dashboard needed a refund button so the business owner could process returns without logging into Stripe. Two problems to solve: pressing the button twice shouldn't issue two refunds, and when Stripe does fail, the owner needs a clear next step — not a raw error message.

The refund endpoint checks whether the order already has a refund_id in Supabase before calling Stripe. If it does, it returns the existing refund instead of creating a new one. If the Stripe call fails for any reason, the UI shows a human-readable message directing the owner to the Stripe dashboard — a place they can actually go to resolve it — rather than surfacing a technical error they can't act on.

Business Impact

  • 0% commission per order — vs. 15–30% on DoorDash or Uber Eats. On a $50 order, that's $7–15 back in the owner's pocket, every time.
  • Replaces $200–500/month in SaaS tools — Square Online, Toast, and similar platforms charge for what this project provides out of the box: online ordering, an admin dashboard, inventory management, and automated reporting.
  • Forkable in hours — the architecture is built to be reskinned for any food business. Swap the menu items, colors, and business name and the full ordering + admin platform is ready to redeploy.
  • 70ms interaction response time — measured via Google's PageSpeed Insights. Clicks and taps register in under a tenth of a second — the kind of responsiveness customers feel even if they can't name it.
Solo project · Node.js · Supabase · Stripe · Live on Render

Outcome

The Bakery Co. is the most technically complete project I've shipped as a web developer. It covers the full stack — frontend UX, backend API design, relational database, real payments, transactional messaging, and automated operations — and it's built around a real problem that real businesses have. Every feature required understanding how the pieces connect, not just how to write code in isolation. Coming from iOS, it gave me hands-on experience with the parts of web engineering that don't exist in the mobile world: server-side auth, webhook event handling, and scheduled background jobs.

What I Learned

  • How Stripe's payment flow works end-to-end — PaymentIntents, webhooks, and why the webhook is the only reliable signal that payment succeeded
  • JWT authentication on a Node.js/Express backend — issuing tokens, verifying them in middleware, and what 401 actually means
  • Supabase as a Postgres backend — relational data modeling, row-level security, and Supabase Auth
  • Transactional email with Resend — HTML email templates, deliverability basics, and when to use email vs. SMS
  • Scheduled cron jobs — running background work on a server without user interaction
  • Express API design — routing, middleware chains, rate limiting, and keeping secrets server-side
  • Deploying a Node.js app to Render with environment variable configuration

Next Iteration

  • Replace node-cron with BullMQ or an external cron service — scheduled jobs currently live inside the Express process and are silently lost on server restart
  • Stripe webhook idempotency keys — the current webhook handler doesn't guard against duplicate deliveries on Stripe retries; adding an idempotency check before writing to Supabase would make it bulletproof
  • Move the co-purchase pair tracking job off the request path — currently runs synchronously on every order save; at scale this should be a background job so it doesn't add latency to checkout confirmation
  • Automated integration tests for the checkout flow — the payment-first architecture is correct but untested at the webhook layer; a test suite with Stripe CLI webhook forwarding would catch regressions