<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Stan</title>
    <description>The latest articles on DEV Community by Stan (@stangineer).</description>
    <link>https://dev.to/stangineer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3908650%2Fd574d054-942b-4f4f-8c04-81657d1f2719.jpg</url>
      <title>DEV Community: Stan</title>
      <link>https://dev.to/stangineer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stangineer"/>
    <language>en</language>
    <item>
      <title>I rebuilt my open-source salon booking template: one page instead of a wizard</title>
      <dc:creator>Stan</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:39:49 +0000</pubDate>
      <link>https://dev.to/stangineer/i-rebuilt-my-open-source-salon-booking-template-one-page-instead-of-a-wizard-301l</link>
      <guid>https://dev.to/stangineer/i-rebuilt-my-open-source-salon-booking-template-one-page-instead-of-a-wizard-301l</guid>
      <description>&lt;p&gt;In June I posted &lt;a href="https://dev.to/stangineer/built-a-full-salon-booking-website-in-a-weekend-heres-the-stack-2fkd"&gt;how I built a salon booking site in a weekend&lt;/a&gt; from an open-source Next.js template called Haar. Since then I've rebuilt almost all of it: the design, the booking flow, checkout, accounts and how it talks to the API. This post is the new version, with the code that matters and the parts I changed my mind about.&lt;/p&gt;

&lt;p&gt;If you just want to click around: &lt;a href="https://template-haar.vercel.app/" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; · &lt;a href="https://github.com/letsopencals/template-haar" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; (MIT).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg6mr81n2r50bcnpm45si.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg6mr81n2r50bcnpm45si.jpg" alt="Haar's home page: the &amp;quot;Issue Nº 17&amp;quot; masthead, &amp;quot;The Art of Hair&amp;quot; and the arched hero photo" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The wizard was the wrong shape
&lt;/h2&gt;

&lt;p&gt;The first Haar booked like most booking widgets do: pick a service, next, pick a stylist, next, pick a day, next, pick a time, next, pay. Each step hid the thing you needed to decide the previous one. You chose a stylist before you knew whether she had anything on Thursday, and you didn't see the price with your options until the end.&lt;/p&gt;

&lt;p&gt;People don't book a salon that way. They think "balayage, Thursday afternoon, ideally with Astrid, but whoever's free is fine". So the new booking page is one page. Numbered sections (service, stylist, day and time, finishing touches) unlock in order, and an appointment card on the right fills itself in as you go, with the total.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27oy6mzspcq1uo1uqm6s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27oy6mzspcq1uo1uqm6s.jpg" alt="The booking page: day ribbon, afternoon time slots with the free stylists' avatars on each, a &amp;quot;Who would you like at 15:30?&amp;quot; picker, and the appointment card on the right" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The part I like most is "Any stylist" mode. Every time slot shows the faces of the stylists who are free at that time, and when you pick a slot you pick the person right there, or "no preference, choose for me". You never hit a dead end where your stylist has no time that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  All of it is one availability call
&lt;/h2&gt;

&lt;p&gt;The availability logic (who can do this service, at which location, for how long, around existing bookings and days off) isn't in the template. It's an API call, made from a Next.js route handler so the key never reaches the browser. This is the template's real route, trimmed a little:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/availability/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/opencals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ProductService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opencals/storefront-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;productId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;productId and date are required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ProductService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentAvailabilities&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timezone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;staffMemberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;staffMemberId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// leave out for "Any stylist"&lt;/span&gt;
      &lt;span class="na"&gt;locationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;locationId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// when rescheduling, the appointment's own slot must not block itself&lt;/span&gt;
      &lt;span class="na"&gt;excludeAppointmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;excludeAppointmentId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;throwOnError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;excludeAppointmentId&lt;/code&gt; is easy to miss: when a customer reschedules, their current booking would otherwise block the slots around it. Passing the appointment's own id lets it move to the slot next door.&lt;/p&gt;

&lt;p&gt;Deep links work too: &lt;code&gt;/booking/balayage?date=2026-11-12&amp;amp;staff=…&amp;amp;location=…&lt;/code&gt; opens the page with those sections already filled, which is handy for "book again" emails and Instagram links.&lt;/p&gt;

&lt;h2&gt;
  
  
  One change you should copy: a private key and the buyer's IP
&lt;/h2&gt;

&lt;p&gt;The June version used a public storefront key (&lt;code&gt;sfk_&lt;/code&gt;). The template calls the API from its own server, so it now uses a private key (&lt;code&gt;spk_&lt;/code&gt;) and forwards each visitor's IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/opencals.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;setupOpencals&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opencals/storefront-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nextBuyerIp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opencals/storefront-sdk/next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;setupOpencals&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENCALS_API_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.opencals.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENCALS_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// spk_…&lt;/span&gt;
  &lt;span class="na"&gt;buyerIp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextBuyerIp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the IP forwarding, every visitor's sign-in, cart and checkout request looks like it comes from your Vercel function, so they all share one rate limit. With &lt;code&gt;nextBuyerIp&lt;/code&gt;, the API counts each visitor separately. You create the key in the Opencals dashboard under Users → your user → API Keys; on Vercel, leave the allowed-IPs list empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserving holds the chair
&lt;/h2&gt;

&lt;p&gt;When you press Reserve, the appointment goes into a cart and the card flips to "Held for you" with a countdown. Nobody else can take that slot while it's held, and if checkout takes longer, there's an Extend button next to the timer. I wrote separately about &lt;a href="https://dev.to/stangineer/prevent-double-booking-system-design-that-holds-2026-o2i"&gt;why holds are the hard part of preventing double bookings&lt;/a&gt;, if you want the database side of it.&lt;/p&gt;

&lt;p&gt;Checkout is three numbered sections next to the stack of appointment cards: your details, a few questions the salon sets, and payment. The payment options are card through Stripe (including Apple Pay and Google Pay), pay at the salon, bank transfer, or free bookings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F99ys8r189mdgesrlgpac.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F99ys8r189mdgesrlgpac.jpg" alt="Checkout: &amp;quot;Reserve your chair&amp;quot;, three numbered sections with Pay at the salon selected, two appointment cards with a finishing touch, the total and a &amp;quot;Held for you 04:25 / Extend&amp;quot; timer" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Accounts without passwords
&lt;/h2&gt;

&lt;p&gt;Customers can now sign in with a 6-digit code sent by email (a password still works), and links in booking emails sign them straight in. The account page is a "salon diary": the next visit with a countdown, past and upcoming appointments, orders as receipts, and reschedule or cancel where the salon allows it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkjz2ivg37o6620r1qozy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkjz2ivg37o6620r1qozy.jpg" alt="The customer account: &amp;quot;Your salon diary&amp;quot; with the next visit and a countdown" width="800" height="786"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile first, for real this time
&lt;/h2&gt;

&lt;p&gt;On a phone, the appointment card becomes a sticky "Your card · $80" bar at the bottom that opens as a sheet, and checkout gets a sticky pay bar.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb7gmv4iwazcs2kkyb2tz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb7gmv4iwazcs2kkyb2tz.jpg" alt="Mobile: the home page, the day and time picker with the sticky card bar, and the &amp;quot;Held for you&amp;quot; sheet with the countdown" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create a free dev store at &lt;a href="https://app.opencals.com" rel="noopener noreferrer"&gt;app.opencals.com&lt;/a&gt; and choose the &lt;strong&gt;HAAR Salon&lt;/strong&gt; dataset when asked. It seeds the same services, stylists and locations as the demo.&lt;/li&gt;
&lt;li&gt;Create a private storefront key (&lt;code&gt;spk_&lt;/code&gt;) under Users → your user → API Keys.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Deploy with Vercel&lt;/strong&gt; in the &lt;a href="https://github.com/letsopencals/template-haar" rel="noopener noreferrer"&gt;README&lt;/a&gt; and set two variables: &lt;code&gt;OPENCALS_API_KEY&lt;/code&gt; (your &lt;code&gt;spk_&lt;/code&gt; key) and &lt;code&gt;AUTH_SECRET&lt;/code&gt; (any random string).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Locally it's the usual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/letsopencals/template-haar.git
&lt;span class="nb"&gt;cd &lt;/span&gt;template-haar
npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# OPENCALS_API_KEY=spk_… and AUTH_SECRET=…&lt;/span&gt;
npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All the salon copy (name, hero, team, hours, contact) lives in &lt;code&gt;lib/site-config.ts&lt;/code&gt;, and colours and fonts are CSS variables in &lt;code&gt;app/globals.css&lt;/code&gt;, so a rebrand for a client is mostly those two files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to know before you build on it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The template is MIT; Opencals isn't open source.&lt;/strong&gt; The engine behind the API (availability, carts, checkout, accounts) is our hosted service. What's open is the templates, the &lt;code&gt;@opencals/storefront-sdk&lt;/code&gt; package and our Agent Skills.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dev store is free and has every feature, but it's in test mode.&lt;/strong&gt; It can't take real payments until you switch the store to production (&lt;a href="https://opencals.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deposits and no-show fees are still in beta&lt;/strong&gt; on the Opencals side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  It's a series now
&lt;/h2&gt;

&lt;p&gt;Haar was the first template. There are now ten, all on the same SDK, each built around how that kind of business actually books:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-frisor" rel="noopener noreferrer"&gt;Frisor&lt;/a&gt;: barbershop, dark editorial design&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-clarity" rel="noopener noreferrer"&gt;Clear Care&lt;/a&gt;: medical clinic, department-first booking&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-alba" rel="noopener noreferrer"&gt;Alba&lt;/a&gt;: dental group, a tooth-chart "Smile Check" and a chair-time planner&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-volt" rel="noopener noreferrer"&gt;VOLT&lt;/a&gt;: padel and squash clubs, a live court grid&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-noir" rel="noopener noreferrer"&gt;NOIR Drive&lt;/a&gt;: car rental by the day, with chauffeur packages&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-fluently" rel="noopener noreferrer"&gt;Fluently&lt;/a&gt;: language school, group classes with live seats&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-bali-villas" rel="noopener noreferrer"&gt;Lumbung Sidemen&lt;/a&gt;: villa rental, nightly stays&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-reformer" rel="noopener noreferrer"&gt;Tensile&lt;/a&gt;: reformer Pilates timetable with beds left&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/letsopencals/template-gable" rel="noopener noreferrer"&gt;Gable&lt;/a&gt;: coworking, desks, rooms and booths on one day sheet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll write one of these posts per template, about the booking problem each one had to solve, so follow the series if that's useful. And if there's a business type you'd want next, tell me in the comments. Tattoo studios and day spas are high on my list.&lt;/p&gt;

&lt;p&gt;— Stan, building &lt;a href="https://opencals.com" rel="noopener noreferrer"&gt;Opencals&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Prevent Double Booking: System Design That Holds (2026)</title>
      <dc:creator>Stan</dc:creator>
      <pubDate>Thu, 29 Oct 2026 07:22:30 +0000</pubDate>
      <link>https://dev.to/stangineer/prevent-double-booking-system-design-that-holds-2026-o2i</link>
      <guid>https://dev.to/stangineer/prevent-double-booking-system-design-that-holds-2026-o2i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opencals.com/blog/prevent-double-booking-system-design" rel="noopener noreferrer"&gt;opencals.com&lt;/a&gt;. The original stays updated; check it for the latest version.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The bug report always reads the same way. Two customers booked the 10:30 with the same stylist, both got a confirmation email, and the salon found out when both of them walked in. The code that let it happen looks perfectly reasonable: check whether the slot is free, and if it is, insert the booking.&lt;/p&gt;

&lt;p&gt;That check-then-insert is the whole problem. Run it twice at the same moment and both checks see an empty slot, because neither insert has committed yet. It doesn't need heavy traffic, either. Two people on a Sunday evening tapping the same slot within a few hundred milliseconds of each other is enough, and a booking page that shows "only 2 slots left this week" makes that collision more likely, not less.&lt;/p&gt;

&lt;p&gt;This is the system design I'd use to make double booking impossible rather than unlikely. Most of it lives in the database, which is where it belongs.&lt;/p&gt;

&lt;p&gt;Double booking is a race between two "is this free?" reads, so no amount of checking in application code fixes it. Make the database refuse the second write: in PostgreSQL, an exclusion constraint on a time range per staff member or room; in MySQL, a row lock on the staff member before you check. Treat group classes as a seat counter updated with a conditional UPDATE, and treat a hold during checkout as a real row with an expiry, because the database can't expire it for you. The last gap is payment: a card can succeed after the hold has gone, so confirming a booking has to be idempotent and able to refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  The availability check is a read, and reads don't lock anything
&lt;/h2&gt;

&lt;p&gt;Here's the sequence with two requests, A and B, both for Lena at 10:30 on Thursday:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A: SELECT ... WHERE staff = lena AND overlaps(10:30–11:30)  -&amp;gt; 0 rows
B: SELECT ... WHERE staff = lena AND overlaps(10:30–11:30)  -&amp;gt; 0 rows
A: INSERT booking (lena, 10:30–11:30)                        -&amp;gt; ok
B: INSERT booking (lena, 10:30–11:30)                        -&amp;gt; ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under PostgreSQL's default isolation level, READ COMMITTED, that interleaving is completely legal. Each statement sees data committed before it started, and nothing that A did is visible to B until A commits. MySQL's InnoDB default, REPEATABLE READ, doesn't save you either, because a plain SELECT reads a snapshot and takes no locks.&lt;/p&gt;

&lt;p&gt;The first fix most people reach for is a unique index on &lt;code&gt;(staff_id, starts_at)&lt;/code&gt;. It catches two bookings that start at exactly 10:30. It does nothing for a 10:00–11:00 colour and a 10:30–11:30 cut, which overlap by half an hour and start at different times. Appointments are intervals, and a uniqueness check on a point can't see an interval.&lt;/p&gt;

&lt;p&gt;So the rule I'd hold to: the availability endpoint is a cache of the truth, and it's allowed to be wrong for a second. The write is where correctness lives, and the write has to be able to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let Postgres refuse the overlapping insert
&lt;/h2&gt;

&lt;p&gt;PostgreSQL has had the right tool for this since 9.0, and range types since 9.2: an exclusion constraint. It's a generalised unique constraint where you choose the operator. "Equal staff member and overlapping time range" is exactly the rule you want.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;btree_gist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;staff_id&lt;/span&gt;   &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;during&lt;/span&gt;     &lt;span class="n"&gt;tstzrange&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;no_overlap_per_staff&lt;/span&gt;
    &lt;span class="n"&gt;EXCLUDE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gist&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;staff_id&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;during&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;btree_gist&lt;/code&gt; is there so a plain &lt;code&gt;uuid&lt;/code&gt; column can sit in a GiST index next to the range. The &lt;code&gt;WHERE&lt;/code&gt; clause means cancelled bookings stop blocking the slot without being deleted. The PostgreSQL range-types documentation uses almost this exact example with meeting rooms, which is a good sign you're using the feature for what it was built for.&lt;/p&gt;

&lt;p&gt;Now replay the race. B's insert either fails immediately or, if A hasn't committed yet, waits for A and then fails. It fails with SQLSTATE &lt;code&gt;23P01&lt;/code&gt;, exclusion_violation, which your code catches and turns into the message the customer actually needs: that slot just went, here are the next three.&lt;/p&gt;

&lt;p&gt;If you're on PostgreSQL 18, there's a shorter spelling. A primary key or unique constraint can now end with &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt;, as in &lt;code&gt;UNIQUE (staff_id, during WITHOUT OVERLAPS)&lt;/code&gt;, and the CREATE TABLE docs say it behaves like the equivalent &lt;code&gt;EXCLUDE USING GIST&lt;/code&gt;. The partial &lt;code&gt;WHERE&lt;/code&gt; for cancelled rows isn't available on that form, so I'd still write the explicit EXCLUDE.&lt;/p&gt;

&lt;p&gt;Two details that bite later. Store the range as half-open, &lt;code&gt;[10:30, 11:30)&lt;/code&gt;, so a booking ending at 11:30 and one starting at 11:30 don't conflict; that's the default for &lt;code&gt;tstzrange&lt;/code&gt;. And if a service needs ten minutes of clean-up after it, put the buffer inside the range you constrain. A buffer that only exists in the availability code is a buffer the database will happily let someone book into.&lt;/p&gt;

&lt;h2&gt;
  
  
  A booking usually occupies more than one thing
&lt;/h2&gt;

&lt;p&gt;A massage needs a therapist and a treatment room. A podcast session needs a studio and, sometimes, an engineer. Constrain only the person and you'll double-book the room.&lt;/p&gt;

&lt;p&gt;What's worked for me is splitting "the booking" from "what it occupies":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;allocation&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;booking_id&lt;/span&gt;   &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;resource_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- a staff member, room, chair or bike&lt;/span&gt;
  &lt;span class="n"&gt;during&lt;/span&gt;       &lt;span class="n"&gt;tstzrange&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;active&lt;/span&gt;       &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;EXCLUDE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gist&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;during&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One constraint covers every kind of resource. Creating a massage booking inserts one booking row and two allocation rows in a single transaction, so either both the therapist and the room are free or nothing is written. It also lets each allocation have its own range, which matters when the room needs fifteen minutes to turn over and the therapist doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seats in a class are a counter, not an overlap
&lt;/h2&gt;

&lt;p&gt;An exclusion constraint answers "can these two things coexist?" A yoga class with twelve mats is the wrong question for it: twelve bookings for the same class are supposed to overlap. What you need there is a count that can't pass a ceiling.&lt;/p&gt;

&lt;p&gt;The cleanest version I know is a conditional update on the class session itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;class_session&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;seats_taken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seats_taken&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;seats_taken&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;seats_taken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it returns a row, you got the seats; insert the booking in the same transaction. If it returns nothing, the class is full. Two concurrent requests for the last mat can't both win, because in PostgreSQL's READ COMMITTED mode the second UPDATE waits for the first to commit and then re-checks the &lt;code&gt;WHERE&lt;/code&gt; clause against the new row. The same shape works in MySQL.&lt;/p&gt;

&lt;p&gt;The thing I'd avoid is &lt;code&gt;SELECT count(*)&lt;/code&gt; followed by an insert. That's check-then-insert again, wearing a different hat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without exclusion constraints: lock the person, then look
&lt;/h2&gt;

&lt;p&gt;MySQL has no exclusion constraints, and neither do most databases that aren't PostgreSQL. You can still make the race impossible; you just have to serialise it yourself. Lock a row that every booking for that resource must touch, then do your overlap check, then insert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;START&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;staff&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- one at a time per staff member&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;staff_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ends_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;-- requested end, requested start&lt;/span&gt;

&lt;span class="c1"&gt;-- insert only if the count was 0&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;staff_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ends_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;FOR UPDATE&lt;/code&gt; on the staff row means two bookings for Lena queue behind each other while bookings for Marco carry on in parallel. For a service business that's no real cost: a busy salon takes a few hundred bookings a day, not a few hundred a second. For multi-resource bookings, lock every resource row involved, always in the same order (sort by id), or two transactions will deadlock each other.&lt;/p&gt;

&lt;p&gt;The other options are worth knowing and I'd pick them less often. SERIALIZABLE isolation in PostgreSQL will detect the conflict and abort one transaction with SQLSTATE &lt;code&gt;40001&lt;/code&gt;, but then every booking path needs a retry loop, and that's easy to forget in the one code path nobody tests. Pre-generating slot rows with a unique key works well when every service is the same length on a fixed grid, like 30-minute tennis courts, and falls apart as soon as a 45-minute and a 60-minute service share a calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Holds expire, and constraints can't tell time
&lt;/h2&gt;

&lt;p&gt;Now add payment. Between choosing 10:30 and the card going through, the slot has to be reserved for this customer, or the second customer can buy it while the first is typing a card number. So the hold is a booking row with &lt;code&gt;status = 'held'&lt;/code&gt; and an expiry time, and the exclusion constraint treats it exactly like a confirmed booking. Good.&lt;/p&gt;

&lt;p&gt;The trap: you can't put &lt;code&gt;hold_expires_at &amp;gt; now()&lt;/code&gt; into the constraint's &lt;code&gt;WHERE&lt;/code&gt; clause. PostgreSQL requires that predicate to be immutable, and &lt;code&gt;now()&lt;/code&gt; isn't. The constraint can't know a hold has lapsed, so an expired hold keeps blocking the slot until something removes it.&lt;/p&gt;

&lt;p&gt;I'd handle that in two places. A sweeper job that cancels expired holds every minute or so keeps the calendar clean. And the booking transaction clears any expired hold that overlaps the requested range before inserting, so a customer never loses a slot to someone who closed their tab nine minutes ago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;staff_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'held'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;hold_expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;during&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;staff_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;during&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hold_expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'held'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'10 minutes'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If two customers do this at the same moment, both cancel the stale hold, one insert wins, and the other gets &lt;code&gt;23P01&lt;/code&gt;. The constraint is still the referee.&lt;/p&gt;

&lt;p&gt;How long should the hold be? Long enough for a checkout with 3-D Secure on a phone, short enough that abandoned carts don't make the shop look full. Ten minutes is a common default; give the frontend a way to extend it once checkout has actually started rather than making the initial hold longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The card can succeed after the hold is gone
&lt;/h2&gt;

&lt;p&gt;This is the case I see teams find in production. The customer reaches the bank's 3-D Secure page, gets distracted, comes back eleven minutes later and approves. The hold expired at ten. Someone else booked 10:30 at minute ten and a half. Stripe now tells you the payment succeeded.&lt;/p&gt;

&lt;p&gt;There's no locking trick that makes this go away, because the payment provider doesn't know about your locks. What you can do is make the confirm step honest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hold_expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'held'&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns the row, confirm and send the email. If it returns nothing, the hold was swept, and you refund automatically and tell the customer straight away, with the next free slots in the same message. It's a bad moment for them, and it's still much better than a confirmation email for a chair someone else is sitting in.&lt;/p&gt;

&lt;p&gt;The same statement makes your payment webhook safe to receive twice, which it will be: Stripe says outright that an endpoint can get the same event more than once. The second delivery finds no &lt;code&gt;held&lt;/code&gt; row and does nothing. Use an idempotency key on the call that creates the charge too; Stripe stores the first response for a key and returns it on retries, and can prune keys once they're 24 hours old, so generate one per checkout attempt, not per customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a Redis lock fits, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;A short-lived lock in Redis (&lt;code&gt;SET key value NX PX 5000&lt;/code&gt;) on &lt;code&gt;lena:2026-11-12&lt;/code&gt; can stop two requests from even attempting the same slot, and it's a reasonable way to keep a slow availability computation from running twice. It is not a replacement for the constraint. A lock with a timeout can expire while the process holding it is paused in garbage collection, and then two writers believe they hold it at once. Martin Kleppmann's 2016 essay on distributed locking walks through that failure and why it needs fencing tokens; for a booking system the fencing token is, in effect, the database constraint. Keep the constraint, and treat any lock in front of it as a performance optimisation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shopify's inventory counts units, not hours
&lt;/h2&gt;

&lt;p&gt;If you sell appointments through Shopify, the platform's own guard against overselling is inventory: a variant with a quantity of one can't be bought twice. That's exactly right for a t-shirt and the wrong shape for time. An appointment product doesn't have a quantity that means anything; the same "60-minute massage" variant is sold forty times a week, each one for a different interval, and Shopify's cart has no notion that two of those carts hold the same Thursday at 10:30.&lt;/p&gt;

&lt;p&gt;So on Shopify, the overlap check has to live in the booking app, and the hold has to survive Shopify's checkout, which the app doesn't control. I went through what Shopify actually permits inside checkout in &lt;a href="https://opencals.com/blog/embed-booking-shopify-checkout" rel="noopener noreferrer"&gt;the article on embedding booking in Shopify checkout&lt;/a&gt;; the short form is to ask any booking app what holds the slot while the customer pays, and what happens if that hold expires mid-checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do, and the parts we don't do yet
&lt;/h2&gt;

&lt;p&gt;Opencals is a hosted booking engine, so if you build on it, none of the SQL above is yours to maintain. On the &lt;a href="https://opencals.com/developers" rel="noopener noreferrer"&gt;Storefront API&lt;/a&gt;, you create the appointment for the chosen slot and put it into a cart. Carts expire on their own, there's a cart extend endpoint that adds five minutes (the "extend once checkout has started" pattern from above), and starting checkout validates the cart's items before a payment is created. On Shopify, the slot is held from the moment it goes into the cart for a window the merchant sets, and a Shopify checkout validation function refuses to complete the order if that hold has expired, so the customer is told to rebook inside Shopify's own checkout rather than by an email afterwards. &lt;a href="https://opencals.com/shopify" rel="noopener noreferrer"&gt;The Opencals for Shopify page&lt;/a&gt; shows that message as it appears.&lt;/p&gt;

&lt;p&gt;Now the limits, because they affect how you'd design around us.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhooks aren't live. &lt;a href="https://opencals.com/docs/guides/webhooks" rel="noopener noreferrer"&gt;The webhooks guide&lt;/a&gt; lists the planned appointment and order events and says plainly that they're not yet available. If you mirror bookings into your own database today, you're polling the API, and your copy can lag behind ours.&lt;/li&gt;
&lt;li&gt;Opencals isn't open source, so you can't read our locking code. What's open is the client side: the MIT-licensed Next.js templates such as &lt;a href="https://opencals.com/blog/templates/haar" rel="noopener noreferrer"&gt;Haar&lt;/a&gt; and the &lt;code&gt;@opencals/storefront-sdk&lt;/code&gt; package on npm.&lt;/li&gt;
&lt;li&gt;The free tier is a development store in test mode. It's the right place to try the race yourself with two browsers, but it won't take a real payment until you switch it to production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building this yourself instead, my advice is narrow: write the constraint before you write the availability endpoint. Availability is where the interesting algorithm lives, and it's tempting to start there, but the constraint is the part that stops the salon from finding out about your bug when two customers walk in. Once the database refuses the second booking, the rest is a matter of telling the customer nicely. For the rest of what a booking system asks of you after this, &lt;a href="https://opencals.com/blog/booking-system-for-developers" rel="noopener noreferrer"&gt;the developer's guide to booking systems&lt;/a&gt; covers reschedule links, refunds and reminders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you prevent double booking in a database?
&lt;/h3&gt;

&lt;p&gt;Make the database reject the second booking instead of checking first in application code. In PostgreSQL, add an exclusion constraint such as EXCLUDE USING gist (staff_id WITH =, during WITH &amp;amp;&amp;amp;) on a tstzrange column, with the btree_gist extension installed. In MySQL, lock the staff member's row with SELECT ... FOR UPDATE, check for overlaps, then insert in the same transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't a unique index stop double bookings?
&lt;/h3&gt;

&lt;p&gt;A unique index on staff and start time only catches two bookings that start at exactly the same moment. Appointments are intervals: a 10:00 to 11:00 booking and a 10:30 to 11:30 booking overlap but have different start times, so the index allows both.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the race condition in a booking system?
&lt;/h3&gt;

&lt;p&gt;Two requests check availability at the same moment, both see the slot as free because neither has committed its insert yet, and both insert. It's called check-then-act, and it happens under default isolation levels in both PostgreSQL and MySQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you handle capacity for group classes?
&lt;/h3&gt;

&lt;p&gt;Use a conditional update on the class session, for example UPDATE class_session SET seats_taken = seats_taken + 1 WHERE id = $1 AND seats_taken + 1 &amp;lt;= capacity. If no row comes back, the class is full. Concurrent requests for the last seat can't both succeed, because the second update waits and then re-checks the condition.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should a booking hold last during checkout?
&lt;/h3&gt;

&lt;p&gt;Long enough for a card payment with 3-D Secure on a phone, and short enough that abandoned carts don't make the calendar look full. Around ten minutes is a common default, with an extension once checkout has actually started. Opencals' Storefront API, for example, has a cart extend endpoint that adds five minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a Redis lock enough to prevent double booking?
&lt;/h3&gt;

&lt;p&gt;No. A lock with a timeout can expire while the process holding it is paused, and then two writers think they hold it. Use Redis locks to avoid duplicate work if you like, but keep a database constraint as the final check.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>postgres</category>
      <category>api</category>
      <category>programming</category>
    </item>
    <item>
      <title>Built a full salon booking website in a weekend — here's the stack</title>
      <dc:creator>Stan</dc:creator>
      <pubDate>Tue, 23 Jun 2026 06:08:00 +0000</pubDate>
      <link>https://dev.to/stangineer/built-a-full-salon-booking-website-in-a-weekend-heres-the-stack-2fkd</link>
      <guid>https://dev.to/stangineer/built-a-full-salon-booking-website-in-a-weekend-heres-the-stack-2fkd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, October 2026:&lt;/strong&gt; Haar has since been rebuilt almost from scratch: one-page booking instead of a wizard, "Any stylist" time slots, held chairs, passwordless accounts and a private API key. The current walkthrough, with new screenshots and code, is here: &lt;a href="https://dev.to/stangineer/i-rebuilt-my-open-source-salon-booking-template-one-page-instead-of-a-wizard-301l"&gt;I rebuilt my open-source salon booking template&lt;/a&gt;. The post below describes the June version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The thing nobody warns you about with booking sites
&lt;/h2&gt;

&lt;p&gt;A booking website looks like a weekend project until you actually try to build one. The UI is easy — a service list, a calendar, a "Book now" button. Then you hit the real question: &lt;strong&gt;is this slot actually available?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That answer isn't a row in a calendar table. It depends on which staff member can do this service, which location they're at, how long the service runs, whether they're already booked, days off, buffers between appointments, and — for classes — how many seats are left. Get it slightly wrong and you double-book a stylist, which is a great way to lose a customer on day one.&lt;/p&gt;

&lt;p&gt;I did &lt;strong&gt;not&lt;/strong&gt; want to build that engine again. I've built scheduling logic before and it's the kind of thing that's "done" five times before it's actually done. So this time I let something else own the hard part and spent my weekend on the parts users actually see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Next.js 15&lt;/strong&gt; (App Router)&lt;/td&gt;
&lt;td&gt;Server components for the data fetches, fast deploys to Vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The API responses are typed end-to-end via the SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Styling&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The template ships with it; rebranding is mostly editing tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Booking backend&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Opencals Storefront API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Availability, staff, locations, cart, Stripe checkout, customer accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; (through the API)&lt;/td&gt;
&lt;td&gt;No payments backend of my own to build or maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Claude (Opus 4.8)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;I described the screens; it wrote the React + Tailwind components against the typed SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Vercel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git push&lt;/code&gt; and it's live&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The part I want to be honest about: the &lt;strong&gt;Opencals Storefront API&lt;/strong&gt; is the reason this was a weekend and not a month. It's the booking infrastructure behind &lt;a href="https://opencals.com" rel="noopener noreferrer"&gt;Opencals&lt;/a&gt; (a service-commerce platform I work on), exposed as a plain REST API with a typed TypeScript SDK. Services, availability, staff, locations, cart, checkout, customer self-service — all endpoints I'd otherwise have had to design, build, and debug myself. With the scheduling logic owned by the API and a small typed surface to write against, Claude handled most of the UI components cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Clone the template
&lt;/h2&gt;

&lt;p&gt;I didn't start from &lt;code&gt;create-next-app&lt;/code&gt;. There's an open-source salon template — &lt;strong&gt;Haar&lt;/strong&gt; — that already wires the whole flow to the API (&lt;a href="https://opencals.com/blog/templates/haar?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;announcement post&lt;/a&gt; · &lt;a href="https://opencals.com/docs/open-source/haar?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;docs&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/letsopencals/template-haar.git
&lt;span class="nb"&gt;cd &lt;/span&gt;template-haar
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's &lt;a href="https://github.com/letsopencals/template-haar" rel="noopener noreferrer"&gt;&lt;code&gt;template-haar&lt;/code&gt;&lt;/a&gt; — Next.js 15, TypeScript, Tailwind, MIT license. Services (with variants), staff, multi-location, real-time availability, add-ons, Stripe checkout, and customer accounts are all already connected. There's a &lt;a href="https://template-haar.vercel.app/" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; if you want to click through it first. My weekend was mostly rebranding and tweaking, not plumbing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6p32g2sunxu7twk3ahv7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6p32g2sunxu7twk3ahv7.png" alt="The template's storefront out of the box — service menu with prices and durations, before any rebranding." width="800" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Create a store, seed it with Haar data, and deploy
&lt;/h2&gt;

&lt;p&gt;This is the part that makes it fast. The template reads everything from one Opencals store, identified by a Storefront API key — and you can have a live, populated store in three steps, no code required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a free Dev Store&lt;/strong&gt; in the &lt;a href="https://app.opencals.com" rel="noopener noreferrer"&gt;Opencals dashboard&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the "HAAR Salon" seed dataset&lt;/strong&gt; when prompted. It's built specifically for this template — it pre-loads the exact services, staff, and locations from the &lt;a href="https://template-haar.vercel.app/" rel="noopener noreferrer"&gt;live demo&lt;/a&gt;, so your store isn't empty and the cloned site renders real content from the very first load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a Storefront API Key&lt;/strong&gt; under &lt;strong&gt;Settings → API Keys&lt;/strong&gt; (it starts with &lt;code&gt;sfk_&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then deploy: hit &lt;strong&gt;Deploy with Vercel&lt;/strong&gt; in the README, paste your &lt;code&gt;sfk_&lt;/code&gt; key when Vercel asks for the environment variable, and it builds in about two minutes. That's a working booking site before you've written a line of code.&lt;/p&gt;

&lt;p&gt;Running it locally instead? Drop the key into &lt;code&gt;.env.local&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OPENCALS_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sfk_your_key_here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the key server-side — it identifies your store and not recommended to ship to the browser. The &lt;a href="https://opencals.com/docs/quickstart?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; covers it in more detail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxh2nx0cyxb5uih0t1ly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxh2nx0cyxb5uih0t1ly.png" alt="The whole no-code path in one place — create a store, choose the HAAR Salon seed data, grab your  raw `sfk_` endraw  key in Settings, and deploy with that key." width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Listing services and fetching availability
&lt;/h2&gt;

&lt;p&gt;The whole booking flow is seven typed calls (list services → pick staff → fetch slots → create appointment → cart → add-ons → checkout), and with the &lt;a href="https://www.npmjs.com/package/@opencals/storefront-sdk" rel="noopener noreferrer"&gt;&lt;code&gt;@opencals/storefront-sdk&lt;/code&gt;&lt;/a&gt; each one is basically a one-liner. You initialize the SDK once and the key stays server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @opencals/storefront-sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/opencals.ts — run once, server-side only&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;setupOpencals&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@opencals/storefront-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;setupOpencals&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENCALS_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// sfk_… from Settings → API Keys&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the two calls that drive the whole front page — the service menu and real-time slots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/opencals&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ProductService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@opencals/storefront-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1. List the bookable services (your menu)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ProductService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;services&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Product[] — title, price, duration, staffMembers…&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Real-time availability for one service on a given day&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slots&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ProductService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentAvailabilities&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;services&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-07-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;America/New_York&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// slots come back converted for display&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;getCurrentAvailabilities&lt;/code&gt; call is the part I was dreading building. It already accounts for which staff can do the service, at which location, for how long, and what's already booked — pass an optional &lt;code&gt;staffMemberId&lt;/code&gt; and it filters to one person. I render the slots and move on with my life.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85kpzxdrgn9w7ipvmhxu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85kpzxdrgn9w7ipvmhxu.png" alt="Real-time slots for the selected day and stylist — straight from  raw `getCurrentAvailabilities` endraw ." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From there it's create the appointment, drop it in a cart, optionally attach add-ons, and check out through Stripe. The full sequence is documented step by step in the &lt;a href="https://opencals.com/docs/guides/build-a-booking-page?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;Build a booking page guide&lt;/a&gt;, so I won't paste all seven here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5b0buzex4ny77eiwmg0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5b0buzex4ny77eiwmg0.png" alt="Add-ons (e.g. &amp;quot;+ deep conditioning treatment&amp;quot;) attached to a service before checkout" width="799" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Customer accounts (the part I almost skipped)
&lt;/h2&gt;

&lt;p&gt;I assumed self-service rescheduling would be the thing I'd cut for v1. It wasn't — the API handles customer auth (JWTs) and the account actions, so the template already had a customer portal where someone can log in, see their bookings, and reschedule or cancel without emailing the salon. For a real salon that's the difference between a booking tool and a front desk that never sleeps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fczb7jlort1keuf2udh12.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fczb7jlort1keuf2udh12.png" alt="A logged-in customer rescheduling their own appointment — no phone call, no email" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Make it yours
&lt;/h2&gt;

&lt;p&gt;Rebranding is one file. Everything salon-specific — name, tagline, logo, hero, team, hours, contact, social — lives in &lt;code&gt;lib/site-config.ts&lt;/code&gt;, and the brand colors are CSS custom properties in &lt;code&gt;app/globals.css&lt;/code&gt;. Edit those and Haar stops looking like Haar.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#E8530E&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c"&gt;/* your brand color */&lt;/span&gt;
  &lt;span class="py"&gt;--font-display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'Playfair Display'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Georgia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--font-body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'Inter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You already deployed in Step 2, so a rebrand is just editing that config and pushing — Vercel redeploys on every commit. That's the weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A real &lt;strong&gt;Next.js booking website&lt;/strong&gt;, not a calendar embed&lt;/li&gt;
&lt;li&gt;Real-time availability across multiple staff and locations&lt;/li&gt;
&lt;li&gt;Add-ons, Stripe checkout, and deposits without a payments backend&lt;/li&gt;
&lt;li&gt;Customer accounts with self-service reschedule/cancel&lt;/li&gt;
&lt;li&gt;One codebase you can rebrand per client — MIT, no royalties (relevant if you're an agency)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it without committing
&lt;/h2&gt;

&lt;p&gt;You don't need an account to look around. The &lt;a href="https://opencals.com/docs/api-reference/introduction?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;interactive API reference&lt;/a&gt; lets you browse every endpoint in the browser, no key required. If you want to build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🧬 &lt;strong&gt;Clone the template:&lt;/strong&gt; &lt;a href="https://github.com/letsopencals/template-haar" rel="noopener noreferrer"&gt;github.com/letsopencals/template-haar&lt;/a&gt; · &lt;a href="https://template-haar.vercel.app/" rel="noopener noreferrer"&gt;live demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 &lt;strong&gt;Template write-up + docs:&lt;/strong&gt; &lt;a href="https://opencals.com/blog/templates/haar?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;the Haar announcement&lt;/a&gt; · &lt;a href="https://opencals.com/docs/open-source/haar?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;Haar docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🚀 &lt;strong&gt;Quickstart (5 min):&lt;/strong&gt; &lt;a href="https://opencals.com/docs/quickstart?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=booking-weekend" rel="noopener noreferrer"&gt;opencals.com/docs/quickstart&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;SDK:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/@opencals/storefront-sdk" rel="noopener noreferrer"&gt;&lt;code&gt;@opencals/storefront-sdk&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is Part 1 of a series where I rebuild the whole thing piece by piece — next up, the API calls in full, then Stripe deposits, then rendering availability in React the right way. If you build something with it, drop a comment — I read all of them and I'm curious what verticals people try beyond salons.&lt;/p&gt;

&lt;p&gt;— Stan, building &lt;a href="https://opencals.com" rel="noopener noreferrer"&gt;Opencals&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>tutorial</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
