Skip to content
engineering

How we shipped our studio site in two build days

The real stack behind our site: Next.js, JSON files instead of a database, a three-layer admin, Azure deploys from GitHub Actions, and a phone ping per lead.

Abdulkader Tamer5 min read

A studio site has one job: make it easy for the right person to understand what you do and send you a message. So the question wasn’t “what’s the most impressive stack?” It was “what’s the least we can run that still lets us change everything without touching code?”

The git history tells the honest timeline. The first version — public pages, admin console, backups, the handbook, mobile polish — went in on one Friday. The launch work — service packages, checkout links, booking, lead alerts, analytics and the Azure deploy — landed on a Sunday a few weeks later. Two build days. Here’s what’s in it.

The stack: boring on purpose

Next.js 16 with the App Router, React 19, TypeScript, Tailwind and Framer Motion for the motion. Fonts (DM Sans and DM Mono) are self-hosted, so a build never depends on reaching Google Fonts.

One decision worth calling out: public pages render on every request instead of at build time. Content lives on the server’s disk and is edited from the admin; a page pre-rendered during a CI build would show the CI machine’s content — the starter data — until the next edit. Rendering per request means an edit in the admin is live on the next page load.

No database. JSON files, on purpose

Content — projects, services, team, plans, store products, settings — and the contact inbox are JSON files in one data directory. Uploaded images sit next to them. No database server, no storage bucket, no monthly bill.

That sounds fragile; here’s what makes it safe:

  • Atomic writes. Every save writes a temporary file, then swaps it in. A crash mid-write can’t leave half a file.
  • A lock per file. Two saves to the same collection queue instead of overwriting each other.
  • Daily snapshots. Before the first change of each day, the data is copied into a dated backup folder, and the last 14 days are kept. Yesterday’s version always exists.
  • Full backups from the admin. One button downloads everything — JSON and images — as a .tar.gz. Restoring takes a copy of the current state first.

A seed/ folder in git holds the starter content. A collection is read from there only until it’s first edited in the admin; after that the live copy wins.

Why not Postgres? A studio has dozens of items, not millions, and one person editing them. Files are simpler, trivial to back up and free. Every read and write goes through one module (lib/db.ts), so moving to a real database later means changing one file, not the whole site.

The trade-off is real, though: this needs a server with a persistent disk. It will not work on serverless hosts where the filesystem is read-only. That decision picked our hosting for us.

Admin security in three layers

The admin console edits everything without a deploy, so it’s the most sensitive part of the site. Access is checked in three independent places:

  1. The proxy (proxy.ts) gates every request under /admin before any page code runs.
  2. Every admin page calls requireAdmin() itself. The layout alone isn’t enough — Next can skip re-rendering layouts on client-side navigation, so a check that only lives there can be bypassed.
  3. Every server action goes through a guarded() wrapper that checks the session again. Server actions are just endpoints; they can be called directly, from anywhere, without ever loading the page that contains the form.

Private data — the contact inbox — has one more gate: it’s only readable through a module that checks the session on its own.

Around that: sessions are HMAC-signed with a scrypt-stretched key and expire after seven days; changing the password signs everyone out. Login and the contact form are rate-limited. Uploads are checked by their actual file signature, not their extension, capped at 4 MB and served sandboxed. And there’s a strict Content Security Policy that keeps scripts, images and connections locked to our own origin.

None of this is exotic. The point is that one forgotten check doesn’t open the console.

Deploys: GitHub Actions to Azure App Service

The site runs on Azure App Service — Linux, Node 22 — with the data directory on the App Service’s persistent disk. We use the Basic tier because the free tier doesn’t give you HTTPS on a custom domain or “Always On”.

Deploys are a push to main. A GitHub Actions workflow installs dependencies, type-checks, builds, prunes dev dependencies, zips the result and publishes it. Then it calls /api/health until the new version answers, and fails loudly if it doesn’t. A separate CI workflow builds every push and pull request, so a red cross on a commit means “this would fail to deploy” before it ever reaches production.

One gotcha: the zip breaks the node_modules/.bin/next shortcut, so npm start doesn’t come up on App Service. The startup command has to call Next directly: node node_modules/next/dist/bin/next start. It’s in the handbook now.

We count visitors with Simple Analytics, which doesn’t use cookies, so there’s no consent banner in the way of the content. It’s off unless the server turns it on at build time, and only then does the security policy open — for exactly two hosts, nothing wider.

Every lead pings a phone

A contact form is only as good as your reply time. Every message is saved to the inbox first, then a push notification goes out through ntfy — a free service with no account, where you subscribe to a long, random topic name. Only the sender’s email and the subject are sent; the message itself stays in the inbox. The notification can never block the form for more than a few seconds, and if it fails, the failure shows up in the admin’s error log instead of disappearing.

Every card on the site links to the contact form pre-tagged with what was clicked, so a message arrives as “Package · Agent Sprint”, not just “hi”.

What we’d tell another small studio

  • Pick the storage that matches your size, then hide it behind one module so you can change your mind.
  • Put security checks where the data is, not just where the page is.
  • Make the deploy prove it worked. A health check after every release is a few lines of YAML.
  • Write the handbook while you still remember why things are the way they are.

The site is the first thing we built for ourselves. Most of what we build for clients follows the same rule: the smallest system that does the job, with the sharp edges written down.

Open for new work

Work with Liminal.

Like how we think? We scope every project up front — fixed price, agreed before we build. Pick a package or tell us what you need.