DEV Community

Cover image for I rebuilt my open-source salon booking template: one page instead of a wizard
Stan
Stan

Posted on

I rebuilt my open-source salon booking template: one page instead of a wizard

In June I posted how I built a salon booking site in a weekend 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.

If you just want to click around: live demo · GitHub (MIT).

Haar's home page: the "Issue Nº 17" masthead, "The Art of Hair" and the arched hero photo

The wizard was the wrong shape

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.

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.

The booking page: day ribbon, afternoon time slots with the free stylists' avatars on each, a "Who would you like at 15:30?" picker, and the appointment card on the right

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.

All of it is one availability call

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:

// app/api/availability/route.ts
import '@/lib/opencals';
import { ProductService } from '@opencals/storefront-sdk';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const p = request.nextUrl.searchParams;
  const productId = p.get('productId');
  const date = p.get('date');
  if (!productId || !date) {
    return NextResponse.json({ error: 'productId and date are required' }, { status: 400 });
  }

  const { data } = await ProductService.getCurrentAvailabilities({
    path: { productId },
    query: {
      date,
      timezone: p.get('timezone') ?? undefined,
      staffMemberId: p.get('staffMemberId') ?? undefined, // leave out for "Any stylist"
      locationId: p.get('locationId') ?? undefined,
      // when rescheduling, the appointment's own slot must not block itself
      excludeAppointmentId: p.get('excludeAppointmentId') ?? undefined,
    },
    throwOnError: true,
  });
  return NextResponse.json(data ?? []);
}
Enter fullscreen mode Exit fullscreen mode

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

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

One change you should copy: a private key and the buyer's IP

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

// lib/opencals.ts
import { setupOpencals } from '@opencals/storefront-sdk';
import { nextBuyerIp } from '@opencals/storefront-sdk/next';

setupOpencals({
  baseUrl: process.env.OPENCALS_API_URL ?? 'https://api.opencals.com',
  apiKey: process.env.OPENCALS_API_KEY, // spk_…
  buyerIp: nextBuyerIp,
});
Enter fullscreen mode Exit fullscreen mode

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 nextBuyerIp, 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.

Reserving holds the chair

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 why holds are the hard part of preventing double bookings, if you want the database side of it.

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.

Checkout: "Reserve your chair", three numbered sections with Pay at the salon selected, two appointment cards with a finishing touch, the total and a "Held for you 04:25 / Extend" timer

Accounts without passwords

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.

The customer account: "Your salon diary" with the next visit and a countdown

Mobile first, for real this time

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.

Mobile: the home page, the day and time picker with the sticky card bar, and the "Held for you" sheet with the countdown

Deploy it

  1. Create a free dev store at app.opencals.com and choose the HAAR Salon dataset when asked. It seeds the same services, stylists and locations as the demo.
  2. Create a private storefront key (spk_) under Users → your user → API Keys.
  3. Click Deploy with Vercel in the README and set two variables: OPENCALS_API_KEY (your spk_ key) and AUTH_SECRET (any random string).

Locally it's the usual:

git clone https://github.com/letsopencals/template-haar.git
cd template-haar
npm install
cp .env.example .env   # OPENCALS_API_KEY=spk_… and AUTH_SECRET=…
npm run dev
Enter fullscreen mode Exit fullscreen mode

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

What to know before you build on it

  • The template is MIT; Opencals isn't open source. The engine behind the API (availability, carts, checkout, accounts) is our hosted service. What's open is the templates, the @opencals/storefront-sdk package and our Agent Skills.
  • The dev store is free and has every feature, but it's in test mode. It can't take real payments until you switch the store to production (pricing).
  • Deposits and no-show fees are still in beta on the Opencals side.

It's a series now

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

  • Frisor: barbershop, dark editorial design
  • Clear Care: medical clinic, department-first booking
  • Alba: dental group, a tooth-chart "Smile Check" and a chair-time planner
  • VOLT: padel and squash clubs, a live court grid
  • NOIR Drive: car rental by the day, with chauffeur packages
  • Fluently: language school, group classes with live seats
  • Lumbung Sidemen: villa rental, nightly stays
  • Tensile: reformer Pilates timetable with beds left
  • Gable: coworking, desks, rooms and booths on one day sheet

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.

— Stan, building Opencals

Top comments (0)