I built a trip planning site for my group going to the F1 Canadian Grand Prix in Montreal. It worked great — itinerary calendar, lodging details, photo gallery, activity suggestions, a shared password so only the group could see it. Classic vibe coded single-purpose app: hardcoded destination, hardcoded dates, hardcoded branding, shipped to Vercel, done.
Then I looked at it and thought: this is useful beyond one trip. What if anyone could fork this repo, deploy it, and have their own trip site without touching code?
That question kicked off a 20-hour arc — across several mobile sessions between F1 races — that transformed a static, single-purpose site into a generic, config-driven template, and exposed every security shortcut I'd taken along the way.
The proof that it worked: I deployed a second instance for a completely different trip — CMA Fest 2026 in Nashville, Tennessee. Same codebase, zero code changes, just the setup wizard.
The Starting Point
The original site had "F1 Grand Prix Montreal" baked into the components. CSS variables were named --gradient-f1 and --shadow-f1. The countdown component had hardcoded race dates. The activities page had Montreal-specific categories. The favicon was F1-themed. localStorage keys were F1-prefixed.
It was a good app. It was also impossible for anyone else to use without rewriting half the codebase.
The Architecture Pivot
The core insight was simple: one database row should drive the entire site.
I created a vacation_config table with a single JSONB column. Every piece of configurable data — trip name, destination, dates, timezone, brand color, hero image, lodging details, password hash, LLM provider, encrypted API key — lives in that one row.
vacation_config
├── tripName
├── destination
├── startDate / endDate
├── brandColor / heroImageUrl
├── lodgings[]
├── passwordHash (bcrypt)
├── llmApiKeyEncrypted (AES-256-GCM)
├── llmProvider
└── setupComplete
Every page calls getConfig() server-side and destructures what it needs. No hardcoded values anywhere. Adding a new configurable field is just adding a key to the TypeScript interface — old configs get new defaults via object spread.
This is the pattern that makes fork-and-deploy work. You clone the repo, you get an empty database, and the site is a blank canvas until someone fills in the config.
The Setup Wizard
An empty database isn't useful. Someone needs to fill in that config row, and that someone might not be technical.
The setup wizard is a 6-step client component that walks through everything:
| Step | What it configures |
|---|---|
| Basics | Trip name, destination, tagline, dates, timezone (auto-detected) |
| Branding | Brand color (8 presets + custom hex), hero image URL |
| Lodging | Multiple properties with type-aware display (hotel, Airbnb, VRBO, house, resort) |
| Password | Shared site password |
| AI Generation | Optional — pick an LLM provider, paste an API key, auto-generate activity suggestions |
| Review & Launch | Summary → one-click launch |
When you click Launch, four things happen in sequence: config is saved (password bcrypt-hashed, API key AES-encrypted), database tables are created, the user is auto-authenticated, and they're redirected to the live homepage. The entire setup takes about two minutes.
The Middleware Problem
A static site deployed to your own Vercel project doesn't need sophisticated auth. You share the URL with your group, maybe add a simple password check, and you're done.
A clonable template is different. Every fork is a fresh deployment. The middleware needs to handle two states: not yet set up and set up and running.
I built a two-gate system running in Edge Runtime:
Gate 1 — Setup Check. Is there an HMAC-signed setup-done cookie? If not, redirect to /setup. This cookie is signed with the site secret to prevent client forgery.
Gate 2 — Auth Check. Is there a valid auth token cookie? The token includes a timestamp and a random nonce, HMAC-signed with the site secret. If it's missing, expired, or invalid, redirect to /password.
The edge constraint matters. Next.js middleware runs in Edge Runtime, which means no Node.js crypto module. The entire auth chain — HMAC signing, signature verification, timing-safe comparison — uses the Web Crypto API. The Node.js side (lib/auth.ts) handles bcrypt password hashing and AES encryption, which only run in API routes.
From One Secret to Everything
The user provides exactly one secret: a random hex string generated with openssl rand -hex 32. That single value does triple duty:
- HMAC signing — auth tokens and setup cookies
- AES-256 encryption key — derived via SHA-256 hash for encrypting LLM API keys at rest
- Timing-safe comparison — double-HMAC pattern for constant-time signature verification
Everything else is either auto-provisioned (Vercel Postgres sets POSTGRES_URL, Vercel Blob sets BLOB_READ_WRITE_TOKEN) or entered through the wizard. The user never edits code, never touches a config file, never opens a terminal after the initial deploy.
The Security Audit
This is where the story arc connects to lessons I've written about before.
I've been saying audit your vibe code often. I've written about the spring cleaning process and the phased remediation pattern. So when I decided to open-source this project, I ran a full audit before publishing.
The audit found 15+ vulnerabilities across 4 severity tiers. I expected minor stuff. I got critical findings.
The Critical Tier
The worst findings were structural. The middleware had a blanket pass-through for all /api/* routes — meaning API endpoints were completely unauthenticated. The setup config endpoint had no auth, so anyone who found the URL could overwrite or delete the entire site configuration. Auth tokens had no expiration. And there was a hardcoded fallback secret — 'fallback' — that would activate if the environment variable was missing, making every signature predictable.
These aren't exotic bugs. They're the exact patterns that vibe coding produces: things that work during development and deployment but leave doors wide open.
The High Tier
The OG image endpoint accepted arbitrary URLs with no validation — a textbook SSRF vector that could reach private networks. LLM prompts passed unsanitized user input directly to the model — destination names, PDF document text, all of it unescaped. No data validation existed on any write endpoint. And the password endpoint had no rate limiting — unlimited brute-force attempts.
The Medium and Low Tiers
Signature comparison used string equality instead of timing-safe comparison. The setup cookie was unsigned. Error responses leaked internal details. No security headers. No file size limits on uploads. The Gemini API key was sent as a URL query parameter (logged in server access logs). The middleware's static asset detection used pathname.includes('.') — meaning a crafted path like /settings/foo.bar would bypass auth.
The Fix
I structured the remediation the same way I've done it before: phased commits ordered by severity and dependency graph, not one giant PR.
Commit 1 — Critical fixes. Middleware now enforces auth on all API routes except the auth endpoint itself and public config reads. Setup mutation requires authentication after initial setup. Auth tokens expire after 30 days. The hardcoded fallback secret is gone — a missing env var now returns a 500.
Commit 2 — High fixes. SSRF blocked with private IP detection. LLM inputs sanitized with delimiter-based injection mitigation and output validation. Per-entity input validators on all write routes. Rate limiting on the auth endpoint with IP-based lockout.
Commit 3 — Medium and low fixes. Setup cookie is HMAC-signed. PDF uploads enforce a size limit. Security headers added (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). Gemini key moved from URL to header. Static asset detection uses an explicit extension regex. Client-side error logging sanitized. CSS color injection blocked with a validation function.
Three commits. The same phased pattern. Same principle: merge and test between each phase so you know exactly which change breaks something if it does.
What Changes When You Open Source
Going from "deployed for my group" to "anyone can fork this" changed the threat model fundamentally.
Before: I controlled the deployment. I knew the URL. The password was shared via text message. If something was misconfigured, I'd notice and fix it.
After: Strangers deploy this. They might skip the secret. They might leave the setup endpoint open. They might paste API keys into client-side code. Every defensive measure needs to work without my involvement.
This is why the audit mattered more for open-sourcing than for personal use. A personal deployment with no auth on API routes is sloppy. An open-source template with no auth on API routes is a liability for every person who forks it.
The middleware's two-gate system, the HMAC-signed cookies, the secret-or-500 pattern, the input validation — none of these existed in the original F1 trip site. They exist because the code is no longer mine alone.
Making It Novice-Friendly
The target user is someone who's never used a terminal. That constraint shaped the documentation as much as the code.
The setup guide walks through 8 steps: fork the repo, generate a secret key (with instructions for Mac, Windows, and a web fallback), deploy to Vercel, add Postgres, add Blob storage, redeploy, run the wizard, share with your group. Each step assumes zero technical knowledge.
The README has a one-click Deploy with Vercel button that pre-fills the environment variable prompt. The wizard auto-detects timezone from the browser. Lodging details auto-populate from the property name via AI. The color picker has presets so nobody has to know what a hex code is.
Every friction point I could identify, I tried to eliminate. The person deploying this might be planning a bachelorette party or a family reunion. They're not reading documentation for fun.
The Architecture Lessons
Turning a personal app into a template taught me things that pure greenfield development wouldn't have:
Config-driven beats hardcoded, always. Even if you're building for one use case, storing configuration in a database instead of in component props makes the app fundamentally more flexible. The JSONB column costs nothing and buys everything.
Middleware is the security boundary. In a personal app, auth is a convenience — you know who's accessing it. In a template, middleware is the only thing standing between a stranger's deployment and the open internet. It needs to handle every state: not yet configured, configured but not logged in, logged in, logged in with an expired token.
The setup wizard is the product. For a clonable template, the first-run experience is the product. If someone can't get from fork to functioning site in 10 minutes, they'll abandon it. The wizard isn't a nice-to-have — it's the reason the project works.
Security scales with distribution. A bug in your personal app affects you. A bug in a template affects everyone who forks it. The bar for security isn't "good enough for me" — it's "good enough for the least technical person who deploys this."
By the Numbers
- 28 commits — from hardcoded F1 site to open-source template
- 1 JSONB row — drives the entire site configuration
- 6-step wizard — zero-code setup for non-technical users
- 15+ security vulnerabilities — found and fixed before open-sourcing
- 3 phased commits — for the security remediation alone
-
1 env var — the only thing a user manually configures (
VACATION_HUB_SECRET) - ~20 hours — total transformation time
- 0 lines of code — required from the person deploying it
Top comments (0)