The business and what it needed
Thunder Road Bar & Grill is a bar and restaurant in Midway, North Carolina. It serves burgers, sandwiches, wings, flatbreads and salads from the kitchen, runs a bar with its own hours, takes reservations for tables, and hires staff. Takeout orders go through ChowNow.
The restaurant belongs to the same ownership group as several of the other businesses I build for. It was my first client site, and I still run it.
Someone on a phone wants to see the menu, find out whether the kitchen is open, book a table, order food, or apply for a job. The staff want to hear about each of those without having to watch another system. The owners want the menu and hours to be right without paying someone every time a price changes.
In the owners' terms, they wanted an affordable upgrade. Their old site cost too much, they could not see what they were paying for, and changes were slow. It also looked flashier than the restaurant. Thunder Road is a local bar and grill, and the site was too slick for it. They wanted a site that looked like the place and that they could keep current themselves.
What was there before
Before the rebuild the restaurant had a site run by an outside vendor. The owners describe it as expensive and opaque: they paid for it, but they could not see how it worked or change it themselves, and getting an update made took time. It was polished in a way that did not match the room.
There was no admin the managers could use for the things the restaurant actually handles every week. Menu changes, hours, job openings and photos all went through the vendor.
Version 1: the cPanel build
Starting over
I started the rebuild on October 4, 2025, as a React app built with Vite and styled with Tailwind. The first backend was a small Node server.
The hosting decided the next step. The restaurant's account was GoDaddy cPanel shared hosting, and that host does not allow a long-running process, which a Node server needs. On November 14, 2025 I converted the backend to PHP so it could run under Apache like any other cPanel site. Support for a separately hosted Node and PostgreSQL setup went in the same day and came back out.
The site was live on cPanel by November 20, 2025. The public side was one page with the menu, reservations, an About section with a map, a careers form, and hours and contact dialogs in the footer. The admin covered the menu, media, and job positions and applications.
The first months
In December 2025 I removed the last of the Node backend and finished the PHP API. The same month brought SendGrid for mail, a rich text editor for the About section, branded error pages, and an audit log. Between December 25 and 28 I did a performance pass: fonts hosted on the site, images in several sizes so a phone downloads a phone-sized photo, a hero image that paints without waiting for the app, sizes fixed up front so the page does not jump while it loads, and longer cache lifetimes for files that never change.
In January 2026 the site moved to a permanent dark theme, with DM Sans hosted on the site for the hero and menu. On March 2 the Order Online buttons were wired to the restaurant's ChowNow page.
Tightening it up
From May 2026 I went back through the site with the standards I was settling on across all the client sites. On May 11 the public forms got spam traps. On May 14 secret scanning went into the repository. On May 25 the owner got a System Checks page in the admin. On May 27 photos moved to Cloudflare R2, through a bridge in the PHP code. The same month added the special timing request on reservations, split the public and admin apps, and adopted the shared backend and admin kits the other sites use.
From late May, changes went through pull requests. The August and September work:
- •PR #16 (August 5) added an integration suite that runs the PHP API against a real database on every change.
- •PRs #21, #23 and #25 (August 7) added staff roles, a weekly work schedule and staff account management.
- •PRs #30 to #34 named mail settings by what they do and removed an SMTP block that had never been used.
- •PRs #45 to #53 (August 22) were another performance pass: one font download where there had been two, a hero that rotates and loads one slide ahead, and the first menu card loading first.
- •PRs #54 and #55 (August 22) let managers choose which part of a photo becomes a menu card.
- •PR #57 (August 30) stopped the last owner from switching off their own access.
- •PR #76 (September 11) made a framing belong to the place a photo is used, so the same photo can be framed one way for the hero and another for a menu card.
- •PR #92 (September 15) fixed two bugs on the live cPanel site: the reservations list crashed when it opened, and every reservation showed a day early.
What it taught me
Thunder Road was the first site I built for a client, and it shows in the history. For its first seven months every change went straight to the main branch, and the first merged pull request is #4, on May 23, 2026. I picked a backend before I knew what the host allowed. The real-database test suite came in August 2026, ten months after the first commit.
Every site since has started where this one ended up: pull requests and required checks from the first day, the shared kits, a decision log, and hosting chosen before the first line of backend code. I brought those habits back here too, which is most of what the 2026 work above is.
Why the site moved, and what changed
Why move
By September 2026 the cPanel build worked, but the hosting set its limits. Deploys meant building locally and uploading zip archives. The PHP, the database and the uploads all lived on one shared account. The host's clocks did not agree with each other or with the restaurant. And the other sites in the same family were moving to Vercel and Turso, where a merge deploys, previews are automatic, and one way of working covers every site.
The move kept the React apps the customers and managers already used. What changed was everything under them: Next.js route handlers on Vercel in place of the PHP API, Turso in place of MySQL, and Cloudflare R2 as the only home for photos.
Holding the new server to the old one
A rewrite of an API that works is a good way to break things nobody notices for a month. So before writing the new server, I recorded the old one.
I scripted 287 requests against the real PHP API and saved every answer: what a visitor sees, what each role can and cannot do, sign-ins, lockouts, refusals, uploads and every form. The accounts covered a visitor, staff, a manager, two owners and a brand new staff member. A further 294 cases recorded the PHP helpers on their own: the HTML sanitizer, the validators, the sign-in tokens, the email bodies, the photo crop math and the price encoding.
The Next.js server was built in PR #93 on September 15, 2026 and replays all of it on every change. Where it deliberately answers differently, the difference is a file in the repository with the request it applies to and the reason.
The 19 bugs
Recording a working system carefully turns up what was never working. The recording found 19 bugs in production. Two were serious. A sign-in token kept working for up to 24 hours after its account was deleted or switched off. And any signed-in staff member could read, change and delete job applications, because those routes checked that someone was signed in without checking their role.
The rest were smaller, and each one was real:
- •Archiving a job application blanked its status, because the database quietly stored an empty value for a status it did not list.
- •A reservation date that was not a date was saved as zeros, and a party of zero was accepted.
- •A menu item saved without a price failed and sent me an alert.
- •Staff could not change their own password, and typing the wrong current password signed people out.
- •Confirming a reservation twice emailed the guest twice.
- •A bar shift that ended after midnight could not be entered.
- •Admin times showed an hour or two early.
I wrote each one up as a question with a recommended answer, which came to 20 decisions, and all 20 were settled on September 15. One answer differed from my recommendation: a menu item with no price saves as free, because some items are on the menu only so customers know they exist. The port settled every bug as decided, and the tests hold each answer in place.
Three clocks into one
The worst of the time bugs came from the host. MySQL ran on Mountain time, the web PHP on Central, the command line on UTC, and the restaurant is on Eastern. Every column was converted using the clock that had written it. Turso now stores instants in UTC, with a database rule that refuses anything else, and the site shows and decides everything on Eastern time. The tests run in three different time zones so a mistake cannot hide behind the test machine's own clock.
The cutover
The move ran after closing, in a 26-minute window from 11:14 to 11:40 p.m. Eastern on September 15 (03:14 to 03:40 UTC on September 16).
- •The maintenance page went up, so no visitor could submit a form during the move.
- •I backed up and exported the MySQL database, loaded every row into Turso and checked it against the export.
- •DNS moved to Vercel.
- •A delta import read anything written above the export's highest ids, before and after the DNS change. All three runs found nothing, so no reservation, application or message was lost.
- •The maintenance page came down.
The cutover turned up three traps, all fixed that night or the next day:
- •The cPanel maintenance switch had never worked, because its rule ran after the rule that serves files. PR #94 fixed the order, so the rollback has a working switch.
- •The Vercel command line stored an empty value when an environment variable was piped in. Environment variables are now set through Vercel's API.
- •A production deploy from a local checkout skipped ignored files and shipped a build with no page shells, so unknown addresses answered with errors. Production is built only by the git integration now.
The same day, PR #95 recorded the deletion of three leftover DNS records for an old admin subdomain that pointed at an unrelated site. The next day PR #99 made the home page and the admin go through the app's own route, so the maintenance switch covers them too.
Mail goes through SendGrid only, as it had since December 2025. The domain has no mailboxes. Reservations, applications and messages are sent from a no-reply address on the domain to the restaurant's existing inbox. A guest gets an email only when their reservation's status actually changes. Server errors email me, throttled so one fault sends one alert. Since PR #112 every send is tagged with the site's name in the shared SendGrid account, and since PR #121 site mail carries no open-tracking pixel, because the pixel cost another family site points with spam filters.
After the move
- •PR #97 (September 17) and later #115 made the database client reconnect after a failed transaction. The fix was carried to the other ported sites.
- •PR #106 moved CI onto my own self-hosted runners.
- •PRs #111, #113 and #114 tell someone whose tab is out of date to reload, in the admin and on the public site, before a stale page fails on its next click.
- •PR #117 rewrote the documentation to describe the system that runs.
- •PR #125 gave every page its own canonical address and made the status pages answer with their real status codes.
- •PR #126 added Vercel Web Analytics and PR #127 rewrote the privacy page to match what the site collects.
- •PRs #128 and #129 store the careers form's extra answers with the application and show them in the admin.
- •PR #130 accepts only web links for resumes and never links one that is unsafe.
The public site, page by page
The public site is one page that scrolls through each section. Each section has its own address, so /menu, /reservations, /about, /careers and /contact open the page at that section. Privacy and terms have their own pages.
Header
The header carries Menu, Reservations, About and Careers, the Order Online button, and a quiet staff sign-in link. On a phone it folds into a menu button that opens a drawer.
Hero
A slideshow of photos the managers choose in Settings. When none are chosen, the newest hero uploads stand in, and with no uploads at all six photos of the restaurant ship with the site. Over the photos sit a title, a subtitle and two buttons, all editable.
Menu
The menu is a set of category cards with photos. Tapping a card opens it to show its items, and opening another closes the first, so a phone never scrolls through the whole menu to reach the wings.
- •An item can show two sizes with two prices, such as a small and a large.
- •An item with no price shows with no price.
- •A category can show its items in one, two or three columns, and can hide descriptions when the names say enough.
- •A category without its own photo falls back to a bundled photo when its name matches one of the restaurant's standard categories.
- •A notice above the menu covers anything that applies to all of it.
Reservations
The form asks for name, phone, email, date, party size and special requests. Once a date is picked, the time list fills with slots every 30 minutes across that day's kitchen hours. A guest who wants a time outside them can switch to a special timing request and write what they need. The server checks the date and the party size again before saving.
A reservation is a request. Staff confirm or cancel it in the admin, and a guest who left an email address hears back when they do.
About
The About text comes from the admin as rich text. Beside it is a map that loads only when someone taps it, with links for directions.
Careers
Open positions come from the admin, with a short note when nothing is open. The form asks for name, contact details, position, availability, experience, an optional cover letter and an optional link to a resume. The form takes a link in place of a file upload, so no documents are stored on the site. Extra questions can be added to the form, and the answers are kept with the exact wording asked. The admin has no screen for those questions yet, so they are added in the database.
Footer
The footer has the phone number, the address, social links, Order Online, and two dialogs: Hours, which shows kitchen and bar hours side by side, and Contact, a short message form. The footer's link columns are set in the database, with no admin screen.
Errors and stale tabs
Missing pages show a branded page with the right status code. A visitor whose tab has been open across a new release sees a notice to reload instead of a broken click.
What the owner runs
The admin is at /admin on the same site. There are three roles: owner, manager and staff. The sidebar shows each role only what it can use, and the server checks the role again on every request.
- •Dashboard: counts of pending reservations, new applications and new messages.
- •Menu: categories with drag-to-reorder, columns, a photo and a switch for descriptions, and items with two sizes and prices, availability and a check before saving with no price. The preview of the public menu folds away until someone wants it.
- •Reservations: a list and a detail view, with pending, confirmed, cancelled, completed and archived. Special timing requests carry a badge. At phone width the list turns into cards.
- •Jobs: applications with their status, the answers to any extra questions and the resume link, plus the positions shown on the careers form.
- •Messages: contact form messages with a read flag and a status.
- •Media: the photo library, uploads, and framing. For each place a photo is used, a manager chooses which part of the original shows, and the site cuts that version from the kept original.
- •Staff: accounts, roles, access on or off, and passwords. The last owner cannot lock themselves out.
- •Schedule: shifts by person and day, drafted by a manager and published a day at a time. Staff see only what is published.
- •Audit log: what changed, who changed it and when, filtered, paged and exportable.
- •Settings: business details, hero copy and photos, each section's wording, the About text, footer contact and social links, and kitchen and bar hours.
- •System Checks, for owners: whether the database, photo storage and mail are working.
Infrastructure
- •Domain: trbgmidway.com. The www address redirects to the bare domain.
- •DNS: a Cloudflare zone. The site points at Vercel, and the photo CDN has its own subdomain in front of R2.
- •Hosting: one Vercel project serves the public app, the admin and the API from the same origin. A merge to main deploys production. Previews sit behind Vercel sign-in.
- •Database: Turso. Since September 30, 2026 previews have their own database, photo buckets and mail key.
- •Photos: two R2 buckets. The public one serves every photo and its resized copies. The private one keeps originals for reframing and holds large uploads for a day.
- •Email: SendGrid only, from a key that can send mail and nothing else. The domain has no mailboxes.
- •Analytics: Vercel Web Analytics on the public site since September 30, 2026. No cookies.
- •Security: strict transport security, a content security policy, framing and type-sniffing headers, and no indexing of the admin.
- •Rollback: the PHP build and its database remain on the cPanel host with the cutover backup.
- •CI: lint, types, build, the recorded-request replay and the PHP integration suite on every change, on self-hosted runners, with a weekly security sweep.
Decisions worth explaining
Keeping the React apps through the move
The customers and the managers already knew the site and the admin. Rewriting the screens would have added risk to the move and given the owners nothing they asked for. So the move replaced the server and left the screens alone, apart from admin fixes. That is also what made the recording possible: the same apps talk to either server, so both had to answer the same.
Recording the old system before replacing it
Reading PHP and guessing how it behaves misses what it does in practice. Recording 287 real requests catches every refusal, every odd status code and every field the apps rely on. It also produced the bug list. Most of those bugs had been there since launch, and no one would have found them by reading.
Deciding each bug on purpose
Fixing a bug changes what someone sees. Refusing an old token is plainly right, but whether a menu item can be free is a business decision. Each bug went in as a question with a recommendation, and the one answer that differed, free items, was a case I would have gotten wrong.
Reservations are requests
Reservations are built into the site, and a person confirms each one. There is no third-party booking service, so there is no extra account or monthly bill, and the restaurant decides each table itself.
Online ordering stays with ChowNow
The restaurant takes orders and payment through ChowNow. Rebuilding that on the site would mean taking card payments and everything that comes with them. The site links to ChowNow, and the privacy page says plainly that the site takes no payments.
Large photos on a host with a request limit
Vercel caps a request at 4.5 MB, and a phone photo is often bigger. The admin shrinks photos over 4 MB in the browser first, and anything still too large goes straight to the private R2 bucket by a signed link that expires after a day.
Sign-in without cookies
The admin signs in with a token kept in the browser and sent with each request. With no session cookie there is no cross-site request forgery to defend against. The account and its role are read from the database on every request, which is what fixed the deleted-account bug. Sign-in locks after five wrong passwords in 15 minutes, and rate limits are kept in the database, since Vercel has no shared disk.
Maintenance mode that covers everything
On Vercel a switch makes the public pages answer with a maintenance page and refuses the three public forms, while the admin stays reachable. PR #99 moved the page shells out of the CDN's reach so the switch covers the home page too.
Preview isolation came late
At cutover, preview deployments shared production's database and photo storage. On September 19 previews got their own sign-in secret, because a token signed on a preview would have been accepted by production. On September 30 they got their own database, buckets and mail key. Both belonged in place from the first day.
What it does now
The site is live at trbgmidway.com. Online ordering works, and reservation requests, job applications and messages arrive steadily.
Day to day:
- •The owners and managers rarely edit the site. What they do check is job applications and messages.
- •Reservation requests arrive by email and in the admin, and staff confirm them by hand.
- •Orders and payments go through ChowNow.
- •PageSpeed results for desktop and mobile are measured and published on this case study page, with a link to run the test live.
The repository has 644 commits and 126 merged pull requests, from October 2025 to early October 2026.
Ongoing support
Maintenance is mostly hands-off unless something breaks.
- •Dependabot opens updates, and safe minor and patch updates merge on their own once every check passes. Larger ones wait for me.
- •A weekly security sweep checks every dependency for new advisories. PR #135 on October 5, 2026 gave the scheduled sweep its own Node runtime.
- •Server errors email me, and I fix forward.
- •The PHP build stays on the cPanel host as the way back, and the recorded requests keep the two builds honest with each other.
- •Content changes the owners would rather not make themselves come to me, along with anything that lives in the database without an admin screen, such as the footer links and the careers form's extra questions.
















