The venue and what it needed
Midway Music Hall is a live music venue in Midway, North Carolina. It puts on shows with reserved table seating, runs a Carolina Beach Music series, hosts repeating nights such as jam nights, teaches dance lessons, and rents out a separate hall called The Gathering Place for private events. The venue belongs to the same ownership group as several other businesses I build for, including Midway Mobile Storage and Thunder Road Bar and Grill.
When I started, the venue had no website at all. The owners kept hearing the same line from people who came to a show for the first time: "I live right around the corner and had no idea you exist." They hear it less now.
What they wanted came in three parts. People nearby had to be able to find the venue and see what was on. The staff had to be able to manage the schedule themselves, show by show, without calling anyone. And the venue wanted to take seat reservations online, against the real tables in the room, so a reservation made at midnight would be waiting for staff in the morning.
What was there before
There was no site. People learned about shows from the venue itself and from each other. Reservations came in two ways: a phone call to the box office, or a word with staff in person at a show.
That way of working has not gone away, and it should not. Many of the venue's patrons are older and prefer to reserve by phone or at the door. The system had to make online reservations possible without making the phone and the door second-class. Staff see every request in one place, however it arrived.
Version 1: the cPanel build
The Node start
The repository began on October 22, 2025. The first version was a React app, built with Create React App, over a small Node and Express API talking to MySQL. The very first commit already had the core of what the venue needed: seat requests, a seating chart, a history of layout changes, an admin panel and a form for band suggestions.
Through November the public side took shape. On November 27 a single-page version of the site went together with a section for the 2026 Beach Bands series, along with a guide for deploying on GoDaddy behind Cloudflare. On November 30 and December 1 the seating work landed: a visual layout editor where staff place tables of different shapes, position the stage and size it, plus a media manager and a slideshow at the top of the homepage.
The switch to PHP
The venue's hosting was a GoDaddy cPanel account. That kind of hosting is built to run PHP and MySQL, and a long-running Node process on it is the odd one out. In mid-December I rewrote the API in PHP. The PHP API was converted on December 12, and on December 16 I made it the only backend and removed the Node one. The React app stayed the same throughout, since it only ever talked to the API.
The site launched in December 2025. In the same weeks the admin gained an audit log (December 14), and seat requests gained database locking and transactions (December 17), so two people asking for the same seat at the same moment could not both get it.
Why a schedule system and not a calendar
The obvious shortcut was a Google Calendar embedded in a page. I built a schedule system instead, because a show at this venue is much more than a calendar entry. A show has a category, a price, a door time, a poster, a seating layout, a cutoff after which online requests close, and a switch for card payment. Seat requests belong to a specific show and a specific set of seats on that show's layout. Repeating nights need a year of dates ahead, with the option to skip one. Closed dates need to show as closures. None of that fits in a calendar event, and a calendar cannot hold a reservation against a table.
A calendar is still useful for patrons who want the schedule on their own phone, so a Google Calendar link is planned as an extra way to follow it. The schedule itself stays in the system.
Growing through 2026
The cPanel build grew steadily through 2026, driven by what the staff ran into:
- •February 5: seat request emails, and PayPal as a first payment option.
- •March 3: an announcement banner and popup the staff control, and signing in by email as well as username.
- •March 15 to 18: tiered pricing, multi-day events and door times for each date.
- •March 28 to April 14: workflows for recurring series.
- •April 3: Square checkout for seat requests.
- •April 6: branded error pages and a maintenance mode.
- •May 22 to 24: the public site and the admin split into two separate apps, and Create React App replaced with Vite.
- •May 25: admin system checks.
- •May 27: images moved to Cloudflare R2.
- •June 7: an optional online payment fee.
- •June 18 to 20: the rental workflow for The Gathering Place.
- •July 19: PayPal removed, leaving Square as the only provider (finished in PR #22 on August 8).
Until August 2026 I committed straight to the main branch. From August 3 every change went through a pull request with checks, and the August work shows what that made possible. PR #16 added three roles: owner, manager and box office. PR #21 added a ledger of payment webhooks and a payment report. PR #40 fixed show times in the admin, which had been read in the browser's time zone, so a 6 PM show could read 2 PM on a machine set to UTC; the same PR put the frontend tests into CI, where they had never run. PR #43 let the venue set a time after which online requests close, and PR #44 added a roster of regulars. PR #55 fixed a test that built "already past the cutoff" in a way that, on a Saturday, meant today, so CI failed every Saturday morning.
At the end of the cPanel era the site ran on PHP 8.3 on shared GoDaddy hosting, with MariaDB 10.11, Apache routing, Cloudflare in front, images on R2, mail through SendGrid and payments through Square. A deploy was a staged ZIP, built and checked on my machine, then uploaded.
Why the site moved, and what changed
By September 2026 every site in the ownership group was moving to the same footing: Vercel for hosting, Turso for the database and Cloudflare R2 for files. That brings deploys on every merge, preview copies with their own data, and no shared server of mine to patch. Midway Music Hall was the fourth to move, after Bow Wow's Dog Spa on September 13, Midway Mobile Storage on September 15 and Thunder Road on September 16, and it had live reservations and live payments in it.
A port held to a contract
I did not want a rewrite that behaved "about the same". On September 16 I built a harness that drove the PHP build through real sessions as a visitor, a box office account, a manager and an owner, and recorded every request and response (PR #82). That gave 333 recorded steps, plus 574 cases checking that the small helpers produced the same results in both languages. The new Next.js server had to replay all of them. Where I wanted the new build to behave differently, the difference became a file with a written reason. A recording is never re-recorded just to make a test pass.
The same day I ported the public API (PR #83), the admin API (PR #84) and set up the platform with a rehearsed data move (PR #85). The React apps came across unchanged.
Recording the old build closely turned up 28 bugs in production, and I wrote each one down. A few of them:
- •The public list of events included promoters' phone numbers.
- •There was no rule for password length.
- •Every "Add to calendar" link returned a 404.
- •The calendar files mangled every letter n.
18 questions about which behavior to keep were answered and recorded that day, and the port followed the answers.
Four clocks down to two
Time was the subtlest problem. MariaDB on the host wrote its own timestamps in Mountain time. PHP wrote venue time on some paths and UTC on others. Between them, seat holds lapsed about three hours late, and "this month" in the admin was wrong for three hours at every month's end.
The new database has two kinds of time and no others. A moment in time is stored in UTC and marked with a Z. A time on the venue's own clock, such as a show's start, is stored without an offset. The database's own CHECK rules refuse anything else. The server tests run under four different time zones, so a test that only passes in Eastern time fails in CI.
Locks without row locks
The PHP build protected seats with 15 places that locked database rows while checking them. libSQL, the database behind Turso, has no row locks. Each of those places became a write transaction that takes the database's write lock before its first read. A second visitor trying at the same moment waits, retries, and gets the normal "those seats are taken" answer instead of an error.
Square calls can take many seconds, and nothing should hold the write lock that long. The step that starts a card payment is split around two short transactions, with the call to Square in between.
The bug that ended other people's bookings
Days after the move, PR #158 fixed a serious bug that came with the new database client. Version 0.18 of the libSQL client changed its reconnect step to close every open transaction on the connection. When one visitor's request hit lock contention and reconnected, it could tear down another visitor's booking in the middle. The fix reconnects only when no transaction is in flight. To prove the guard mattered, I removed it and watched five tests fail.
Moving the data
The export ran inside one read-only snapshot over a utf8mb4 connection, because five tables were stored as Latin-1 while holding Windows punctuation such as curly quotes. I rehearsed the whole move on a throwaway Turso database first. Two bad rows needed named corrections: a price of minus one cent, and a date in the year 0026.
The cutover ran on September 17, from about 1:10 to 2:10 in the morning Eastern. I took a backup, put up the maintenance page, exported, loaded 9,596 rows and checked them against the export, moved DNS, and ran 22 checks against the live server. I then compared the old and new databases twice, at the end of the window and 40 minutes later, and both comparisons came back empty. The preview database was refreshed from the new production copy.
What the new footing made possible
A few of the changes made on the new footing stand out.
- •The stale-tab notice (PR #215, September 24). Staff reported the admin "breaking" on their phones. It was a deploy replacing the app's files under a tab that had been open for hours. The admin now checks for a newer version, tells the person, and offers a Reload button. It never reloads for them, and a failed file load is worded differently from a real error. This became the standard for every app I run.
- •One price per show (PR #230, September 25). Shows had separate advance and door prices. When I measured, no show charged two different prices; every apparent difference was one box left empty. Measuring also found a money bug: an advance price of exactly zero beside a real door price was offered for card payment at $0.00. It affected three shows, none of which had payment turned on. Each show now has one price, asked once.
- •Nights listed twice. The first date of a repeating night was itself a dated show, so a jam night could appear twice on the schedule. I fixed the cases that existed, and a system check named nights_listed_twice now catches it by title and date.
- •Holds that match how staff work (PR #187). Staff had been extending open holds by hand. When I measured, 94.6 percent of 1,215 confirmed requests had settled within seven days, so a new request now holds its seats for a week, never past the show, and staff are warned a day before a hold lapses (PR #188).
The public site, page by page
The public site is a React app. Every page goes through the Next.js server first, which writes the page title, description, sharing tags and structured data, and since PRs #255 and #266 the real schedule and show content too, so search engines read the actual listings.
Homepage
The homepage is written for someone who has never been (PR #131). It opens with a slideshow of photos, a short introduction, and a spotlight on what is coming up. A "First Time Here" section answers the questions a newcomer has, with a strip showing how payment works at the venue. A banner or popup the staff control can announce anything they need to, and visitors can dismiss it. The page ends with contact details and a map.
Schedule
Every upcoming show, a whole month at a time (PR #239). Shows with reserved seating are marked, and a filter shows only those (PR #149). A date the venue is closed shows as a notice of its own instead of a show card (PR #135). Each card links to the show's page.
Show pages
Every show and every repeating night has its own address (PR #130). The page shows the poster, date, doors and price, who can come, a Request seats button when seating is open, and a button to call the box office. Published upcoming shows offer an add-to-calendar file. The server writes structured data describing the event, so a search engine can show it as an event.
The seating chart
The seating chart opens on the seat step and uses the whole screen (PR #178). It draws the room from the layout version the show uses, with the stage and every table. Seats already reserved or held are grayed out. The visitor taps the seats they want, and the chart keeps a running count.
Once a show's cutoff passes, online requests close, and the chart says so and points people to the box office. The cutoff is set per weekday and can be changed for a single show.
The reservation form
After choosing seats the visitor gives a name, a phone number and an email address. The phone number is required so staff can confirm. The email is where the confirmation goes, and the form says so. There is room for a note.
On submit, the server checks the seats and records the request in one write transaction, so two people cannot get the same seat. The seats are held for staff to confirm. If card payment is on for that show's category and Square is set up, the visitor can pay right away or later from the link in their confirmation email. One Square payment covers the whole request, up to six seats.
Repeating nights, lessons and the beach series
The repeating nights page lists each regular night once instead of every date (PR #132). The lessons page shows each class's day and times, taken from its first class, and its instructor, and notes any week a class is skipped (PRs #274, #275, #278). The Carolina Beach Music series has its own page, a month at a time.
The Gathering Place
The rental hall has a page of its own. It covers the room, checks a date against the schedule, and offers a short inquiry form. When the venue turns it on, the page also offers the rental packet and a rental application that is filled in right in the browser and sent as a PDF to private storage (PR #113).
Everything else
An archive of past shows, a form for bands that want to play, a contact form, privacy and terms pages that describe what the site actually collects, a payment return page that asks the server whether a payment went through, branded error pages, and a sitemap built from the schedule. Old addresses for closed dates answer that the page is gone for good, so search engines drop them.
What the owner runs
The admin is a second React app. The owners and managers use it for everything; lessons is the part they use least. The box office uses the parts its role allows.
Roles and sections
There are three roles: owner, manager and box office. On top of the role, each account can be narrowed to the sections that person needs. An instructor, for example, is a box office account narrowed to Lessons (PRs #205 to #207). The server enforces both the role and the sections on every request, so hiding a menu item is never the only protection. Accounts are turned off instead of deleted, so history still reads correctly (PR #95), and anyone can ask for a password reset link (PR #98).
Front of house
- •The dashboard shows what needs doing today and what is on this week (PR #104).
- •Seat requests can be approved, denied, extended, held until the show or moved to other seats on the map (PR #139). Staff can record money taken at the door and how it was paid (PRs #196, #209). A request whose confirmation email bounced is marked in red (PR #204).
- •Regulars is a roster of people who come often, with their usual seats for each layout (PR #105). It suggests regulars from past bookings (PR #106), can follow a particular act instead of every show (PRs #159, #160), and lets staff seat a regular before they have paid (PR #240).
The schedule
- •Events is the schedule by month. The show editor covers category, price, doors, layout, the picture and its framing, recurrence, and snapshots of the seating that can be restored. A closed date is its own kind of entry (PR #137), and played shows are put away each night.
- •Recurring Nights and Lessons have their own sections, with series dates, skipped dates, and a way to put a series away and bring it back (PRs #244, #245).
- •Seating Layouts is the visual editor for the room: tables of many shapes, the stage, undo and redo, and arrow keys (PR #119). A new version is recorded only when the map actually changes (PR #120). It refuses to delete a layout a show uses and offers to move those shows (PR #126). It is meant for a tablet or larger and says so on a phone.
- •Event Categories hold the defaults a new show starts with: layout, door time, price, description, inbox address and whether card payment is on.
Inbox, website and payments
- •The inbox holds contact messages, band pitches and rental inquiries in one list (PR #108), with statuses and notes (PR #110). Staff can reply from inside the admin, and the reply is recorded (PR #273). Rentals lists completed applications above inquiries. The Email screen shows every address the site sends to (PR #272).
- •Site Content edits the banner and homepage text. The media manager handles posters and photos with framing for each place a picture appears, and asks what a file is used for before it can be deleted (PR #123).
- •The payment report lists Square sales and separates online payments from sales on the venue's own terminal (PR #88). Payment settings lead with whether online payment works at all, in plain words (PR #183).
Maintenance
System Checks confirms the stores are reachable and the email setup is complete, and runs checks for real problems: the nightly jobs ran, repeating dates are filled a year ahead, lessons are on the website, no night is listed twice, every show taking money has a price, and upcoming shows have pictures. Who Did What tells the audit log as a story instead of a table of codes.
The whole admin works at phone width, with a menu drawer and tables shown as cards (PRs #192 to #194).
Infrastructure
- •Domain and DNS: a Cloudflare zone. The bare domain points at Vercel and www redirects to it. The CDN subdomain for images and the mail records were left as they were.
- •Hosting: a Vercel project in the JAMARQ Digital team. A merge to main deploys production. Builds are skipped for changes outside the deployable paths.
- •Database: Turso, with separate production and preview databases. The schema has 38 tables with strict types, money kept in whole cents, and the two kinds of time described above. Each schema change is a dated SQL file that I run by hand on preview and production. Nothing changes the database on deploy.
- •Files: two Cloudflare R2 buckets. Public images sit behind a CDN subdomain, and rental applications and uploads in progress sit in a private bucket. Files too large for a normal request go straight from the browser to R2 through a signed link that expires in an hour. Images are resized on the server with sharp.
- •Email: SendGrid, on one shared account. Every message carries the site's tag, a plain-text part and no tracking, and the key can only send. Bounces on seat request confirmations come back through a signed webhook. Server errors are emailed as alerts (PR #271).
- •Payments: Square hosted checkout, confirmed by a signed webhook. Previews use Square's sandbox.
- •Nightly jobs: three Vercel cron jobs in the early morning. One clears old audit entries, sessions and throttle rows. One puts played shows away. One fills repeating nights and lessons a year ahead.
- •Previews: every preview has its own database, its own storage buckets, its own mail key with mail off, and Square's sandbox.
- •Analytics: Vercel Web Analytics and Cloudflare Web Analytics, on public pages only. The admin and payment pages are not counted.
- •Security: a session cookie with idle and absolute timeouts, a check on the origin of every admin change, sign-in lockout after repeated failures, salted hashes in place of visitor addresses, hidden trap fields on the public forms, and limits on rental submissions.
- •CI: GitHub Actions on my self-hosted runners, running the contract replay, the server tests and the old PHP suite.
Decisions worth explaining
Staff confirm every request, paid or not
A paid request does not confirm itself. The venue seats people by judgment as well as by the chart, and regulars have their usual seats. Payment marks a request as paid, and a person still says yes. The same holds for requests that come in by phone and at the door.
Card payment by category
Online payment is switched on one category of shows at a time (PR #189). The venue takes card payment online on some shows and leaves the rest to the box office and the door, where most of its card sales still happen on its own Square terminal. The settings page says plainly whether online payment can work at all before it offers any switch.
Holding a card at the moment of booking was planned as a second stage and shelved on September 23. The current flow holds the seats first and takes payment after.
One way to classify a show
Shows used to carry free-form tags as well as a category. Counting by tags gave confident wrong answers, such as only three beach shows when there were eighteen. The tags column is gone, and the category is the only classification (PRs #224, #238).
The PHP build stays
The PHP code and its MySQL schema stay in the repository as the reference the new server is tested against, and CI still runs the PHP test suite against MariaDB. The cPanel deploy and its database are still in place, with a backup taken before the cutover, as the way back if it were ever needed.
Local runs cannot touch production
A local run of the site writes files to the local disk even if storage keys happen to be in the environment. Files go to R2 only from production or from Vercel. Mail sends only when the app is production, sending is switched on, and a key is set. A developer machine can exercise every feature without a file reaching the live bucket or an email reaching a patron.
Alerts that actually alert
The documentation promised that server errors would be emailed, and the code only logged them. PR #271 made the code match the promise. The same habit led to PR #253, where a band pitch now records whether its notification email went out, so a lost message is visible in the inbox.
What it does now
The site is live at midwaymusichall.net. People find the venue, see the schedule, pick seats on the chart and send their requests at any hour. Reservations now come in online, and the staff say every part of running the venue is easier.
Day to day:
- •The owners and managers use the admin for the whole operation: the schedule, repeating nights, seat requests, regulars, rentals, the inbox and the site's pictures. Lessons is the module they use least.
- •Many older patrons still reserve by phone or in person at shows. Staff work those alongside the online requests, on the same seating chart.
- •Online seat reservation with card payment works. Payment is on for some shows, and the rest are paid at the venue.
- •Hall rental inquiries and applications arrive through The Gathering Place page and land in the admin.
Ongoing support
Maintenance is mostly hands-off unless something breaks.
- •Dependabot opens dependency updates. Minor and patch updates merge on their own once every check passes, and larger ones wait for me.
- •A weekly scheduled security sweep checks the dependencies. The security gate blocks on real advisories, with one written exception for a build tool that expires on its own date (PR #267).
- •Every change runs the contract replay, the server tests and the PHP suite before it can merge.
- •Schema changes go to the preview database first, then production, by hand.
- •The next addition is a Google Calendar link, so patrons can follow the schedule from their own calendar as well as the site. The schedule system stays the place where shows are made.


















