Building Flex Engineers: A Studio Site That Doubles as a Systems Demo
Most agency marketing sites are the least interesting thing an agency ships — a template with a hero, some logos, a contact form. I wanted Flex Engineers' site to be the opposite: the first proof point a prospective client sees, running on the same stack decisions I'd defend in a client project. So it's built as a "mission control" HUD — corner brackets, a terminal you can type into, scan-line overlays — but underneath the sci-fi skin is a fairly serious Next.js 16 / React 19 app with a self-hosted analytics pipeline, several live Three.js scenes, and a deliberately honest "AI" demo that doesn't call an LLM.
The stack, and why each piece
- Next.js 16 (App Router) + React 19 — routes under
src/app(/,/services,/projects,/contact,/analytics), withbabel-plugin-react-compilerturned on so I don't hand-rolluseMemo/useCallbackin components that are already dense with animation state. - Tailwind CSS 4 + Framer Motion for the visual layer — glass panels, glow edges, holo-flicker scan effects, all composed as small utility classes rather than one-off CSS files.
- React Three Fiber / drei / three.js for four distinct 3D scenes: an AI "brain/core" on the homepage, a process visualization, a mission-request backdrop, and a spinning visitor globe on the internal analytics dashboard.
- Neon (serverless Postgres) as the analytics store — no third-party analytics SaaS.
- Web3Forms as the contact form backend, so there's no email server or SMTP credentials to manage.
- Recharts for the analytics dashboard's trend charts.
Self-hosted analytics instead of bolting on GA
I didn't want Google Analytics cookie banners or a black-box dashboard for a site whose entire pitch is "we build custom software." So /api/analytics writes directly to a visitors table in Neon via @neondatabase/serverless, and /analytics renders a dashboard from that same data — top countries, top pages, UTM sources, a 14-day trend chart, and a rotating 3D globe plotting visitor countries by lat/lng.
The schema evolved after launch (UTM fields, timezone, session duration), and rather than write a migration tool for a single-table hobby-scale schema, ensureSchema() just runs an idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS on cold start:
async function ensureSchema() {
await sql`CREATE TABLE IF NOT EXISTS visitors (...)`;
// Added after the initial launch — IF NOT EXISTS makes this a no-op
// once a table already has them, so it's safe to run on every cold start.
await sql`
ALTER TABLE visitors
ADD COLUMN IF NOT EXISTS utm_source TEXT,
ADD COLUMN IF NOT EXISTS utm_medium TEXT,
ADD COLUMN IF NOT EXISTS utm_campaign TEXT,
ADD COLUMN IF NOT EXISTS timezone TEXT,
ADD COLUMN IF NOT EXISTS duration_seconds INT
`;
}
That's the right amount of ceremony for a serverless function that might cold-start on any request — no migration runner, no separate deploy step, just a schema statement that's safe to re-run forever.
Session duration was the trickier part. A page view fires on mount, but "how long did they stay" only resolves when they leave — and beforeunload handlers are unreliable, especially on mobile Safari. The tracker instead listens for pagehide and uses navigator.sendBeacon (falling back to a keepalive: true fetch) to flush the duration asynchronously without blocking navigation:
function sendDurationBeacon(id: number, durationSeconds: number) {
if (durationSeconds <= 0) return;
const payload = JSON.stringify({ id, durationSeconds });
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/analytics/duration", new Blob([payload], { type: "application/json" }));
} else {
fetch("/api/analytics/duration", { method: "POST", body: payload, keepalive: true }).catch(() => {});
}
}
It's a separate endpoint from the main /api/analytics POST rather than a PATCH, specifically because sendBeacon only ever issues POST — a small constraint that shaped the route design instead of fighting it. The dashboard itself caches the aggregated summary for 30 seconds in memory (getCachedAnalyticsSummary), which is enough to keep repeated dashboard loads from re-running six aggregate queries against Neon every time.
Gating Three.js behind the viewport, not behind a loading spinner
Four separate WebGL scenes on one page is a real performance risk if they all mount immediately. Instead of one global "is Three.js ready" flag, each scene is wrapped in a small ViewportCanvas component built on IntersectionObserver:
export function ViewportCanvas({ children, rootMargin = "35% 0px", fallback = null }: Props) {
const [active, setActive] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), { rootMargin });
observer.observe(containerRef.current!);
return () => observer.disconnect();
}, [rootMargin]);
return <div ref={containerRef}>{active ? children : fallback}</div>;
}
Paired with next/dynamic(..., { ssr: false }) for every scene component, this means the visitor globe on /analytics never even downloads its Three.js chunk until it's scrolled within 35% of the viewport, and it unmounts again once it scrolls away. It's a pattern I'd reuse on any client project with heavy client-only visualizations — dashboards, editors, map widgets — where "lazy-loaded" alone isn't enough and you also need "lazy-mounted."
An "AI brief" that's honest about not being AI
The homepage has a box: "What would you like to build today?" — type a description, hit Analyze, and it returns a list of detected modules (Patient Records, Doctor Scheduling, Billing... for a hospital description; Cart & Checkout, Payments, Order Tracking... for an e-commerce one). It reads like an LLM call. It isn't one — it's a keyword-to-module lookup table:
const RULES: Rule[] = [
{ keywords: ["hospital", "clinic", "patient", "doctor", ...],
modules: ["Patient Records", "Doctor Scheduling", "Billing", ...] },
{ keywords: ["ecommerce", "shop", "store", "marketplace", ...],
modules: ["Product Catalog", "Cart & Checkout", "Payments", ...] },
// 19 rules total, covering common verticals
];
export function detectModules(input: string): string[] {
const matched = new Set<string>();
for (const rule of RULES) {
if (rule.keywords.some((k) => input.toLowerCase().includes(k))) {
rule.modules.forEach((m) => matched.add(m));
}
}
if (matched.size === 0) ["User Interface", "Database", "Business Logic", "Reporting"].forEach((m) => matched.add(m));
BASELINE_MODULES.forEach((m) => matched.add(m)); // Auth, Admin Dashboard, API Layer
return Array.from(matched);
}
I could have wired this to an actual LLM API — the site's SEO keyword list even name-drops Claude and OpenAI — but for a marketing-page demo that every visitor can trigger for free, an API call adds latency, cost, and a failure mode (rate limits, timeouts) for zero real benefit: the deterministic version returns the same well-formed, on-brand results every time, instantly, with a fake 1.1s "Analyzing..." delay purely for pacing. Knowing when not to reach for an LLM is as much a part of the job as knowing when to.
Small decisions that add up
| Decision | Reasoning |
|---|---|
| Mobile users get a blocking "open on PC" modal | The site leans on WebGL and scroll-triggered motion; a broken mobile experience is worse than an honest redirect. Dismissal is remembered per-tab via sessionStorage. |
A working terminal in the corner (help, services, projects, hire) | Reinforces the mission-control theme functionally, not just visually — it's real client-side routing, not a gimmick modal. |
Contact form via Web3Forms with a hidden botcheck field | No backend mail server needed, and a honeypot input (tabIndex={-1}, autoComplete="off") filters basic bots without a CAPTCHA. |
IP-based geolocation via ip-api.com, with private-IP short-circuiting | Keeps analytics free of a paid geo-IP vendor for a low-traffic site, while isPrivateIp() avoids wasting lookups on local/dev traffic. |
Takeaway
The interesting engineering here isn't any single library — it's the discipline of matching each piece of the stack to what it's actually for: Postgres and hand-written SQL instead of an analytics SaaS because the data model is simple and I wanted full ownership of it; IntersectionObserver gating instead of a global 3D loading state because the performance problem was per-section, not global; a lookup table instead of an LLM call because the "AI" demo needed to be free, instant, and deterministic, not actually intelligent. That's the same lens I bring to client work — pick the boring, correct tool for each sub-problem, and save the novelty budget for the parts of the product that actually need it.