Self-hosted class booking and membership platform for Santa Barbara Yoga Collective. Drops into their existing Squarespace site as a direct replacement for Acuity Scheduling — same embed points, new src. The studio owns its booking data, checkout branding, and member records for the first time.
(public) (member) (admin)@clerk/nextjs v62026-04-22.dahlia)next/font/google--font-display / --font-sansglobals.css + Tailwind config
NEXT_PUBLIC_APP_URL for absolute URLsThe core constraint is that the studio's Squarespace site stays intact. Acuity Scheduling is an iframe embed. This system is also an iframe embed. Replacing Acuity means updating the src attribute on two existing embed blocks — the public schedule and the member portal. No Squarespace changes, no SEO disruption, no marketing content rebuilt.
Route groups ((public), (member), (admin)) are purely organizational — they add no URL prefix. middleware.ts classifies every incoming route and hands off to Clerk for session validation on protected paths. The admin layout server component checks an ADMIN_EMAILS allowlist and domain before rendering. Membership state currently lives in localStorage under key sbcyc-membership-v1; both the cart and membership move to Prisma queries in M5.
The schedule is driven by a WEEKLY_PATTERN — 18 recurring class slots, each with day-of-week, time, instructor, room, capacity, and pricing. getSessionsForWeek(weekOffset) materializes that pattern into concrete dates for any given week. One-time sessions carry a cancelsPatternSlot field to model schedule exceptions without touching the underlying pattern. This makes the calendar view (which needs 2+ months) a loop over offsets 0–8 with no database or API call.
The studio owner has years of Squarespace content — instructor bios, blog posts, SEO-indexed pages. Replacing the marketing site to gain control of booking would discard all of that. Acuity is itself an iframe. Replacing only what Acuity does means the studio's Squarespace workflow is unchanged and the scope stays on the booking engine rather than the entire web presence.
A studio booking system handles payment data and personal information. Custom auth is where small projects most reliably introduce security vulnerabilities. Clerk provides PKCE, token rotation, MFA, and OAuth. The only custom work is a route-classification middleware, an admin email allowlist in the layout server component, and a forceRedirectUrl pattern that returns students to their checkout after sign-in.
When STRIPE_SECRET_KEY is absent or set to a placeholder, checkout routes to /checkout/mock — a purpose-built page with a card form, 1.2-second fake processing delay, success animation, and localStorage cart clear. The full booking flow is demonstrable with zero configured credentials. Switching to production requires setting one environment variable.
Building the complete UI against typed seed data and getting sign-off on the schedule, checkout, and admin flows before wiring live queries is lower risk than building both layers simultaneously. The gap between "working with seed data" and "working with live data" is real — error states, loading states, pagination, validation — and M5 addresses it as a focused milestone rather than a deferred afterthought.
Member pricing logic needed consistent behavior in three execution contexts: the React checkout page (client), the checkout API route (server), and the BookingButton component (client). A shared utility would be cleaner, but type constraints across client/server module boundaries created more friction than the duplication. The function is deliberately trivial — three cases, no branching — so the maintenance risk of three copies is low. When the codebase stabilizes, this consolidates into a shared package.
Route groups are organizational — they don't add a URL prefix. That means app/(admin)/schedule/page.tsx and app/(public)/schedule/page.tsx both resolve to /schedule, which Next.js rejects as a duplicate route at build time. The fix is an explicit admin/ subfolder inside the (admin) group: app/(admin)/admin/schedule/page.tsx resolves to /admin/schedule. The error message doesn't immediately surface route groups as the cause, and the build fails hard rather than warning.
The Stripe webhook route originally instantiated new Stripe(key) at module load time. Next.js evaluates route modules during the build to generate the route manifest, so a missing key crashes the build before the server starts. The fix: import a shared lib/stripe.ts module that returns null in demo mode, and check isDemoMode at the top of the handler before touching the Stripe client. The module never instantiates when no key is present.
In development, Clerk's session token setup requires a real browser — it sets a dev-browser cookie via JavaScript that isn't present in curl requests. Protected routes return a 404-shaped response (Clerk rewrites the request to an internal URL that Next.js doesn't find) rather than a 401. The routes are correct; the server is healthy. The pattern only becomes clear once Clerk's dev-browser mechanism is understood. In production with real cookies, this doesn't occur.
In Next.js 15, params and searchParams in server components are now Promises and must be awaited before use. In v14 they were synchronous. The change produces silent type errors — TypeScript doesn't flag the missing await in all cases because the unresolved Promise still passes type-checks in some contexts — and the runtime behavior is wrong values rather than thrown exceptions. Every dynamic route and search-param-reading page needed an audit after this was identified.
/checkout/mock). All schedule and booking data (seed files). Membership state (localStorage). Supabase connected and all 11 tables live; not yet queried.