{"id":"5nvrgzy8ssc0zso","title":"How This Site Is Built (Reference Stack)","slug":"how-this-site-is-built","summary":"The working reference build: React 19 and Vite on the front, self-hosted PocketBase behind a public API, and a full /admin operator console in the same repo—inspect the network tab and read how it ships.","imageUrl":"https://briancrabtree.me/images/journal-how-site-built.webp","category":"Engineering","date":"2026-06-01T18:00:00.000Z","featured":false,"likes":15,"author":"Brian Crabtree","content":"<h2>Why this site is the reference build</h2>\n\n<p>Most agencies show you mockups and case studies with the client name stripped out. That is fine when NDAs require it, but it leaves a gap. You never get to see how the developer thinks when nobody is watching. I use this site as the fill-in for that gap. The journal, the contact pipeline, the admin console, the flags, the sitemap refresh, the image checks — all of it is real code in a real repo, deployed to a real hostname. You can read the field notes, submit the contact form, and watch the network tab while the home page loads. That is intentional.</p>\n\n<p>The design is deliberately sharp: black field, acid accent, no component library pretending to be a brand system. I write vanilla CSS because I do not want a framework fighting me on every layout tweak, and I do not want future-you paying rent on abstractions you never needed. React handles structure and state. CSS handles the visual system. The split stays clean.</p>\n\n<p>If you are comparing vendors, ask where their reference build lives. If the answer is a Dribbble shot and a PDF, you are buying a promise. This site is the working proof I point to when NDAs hide everyone else's logo.</p>\n\n<pre><code>./deploy.sh prod\n# build → prerender shells → sync dist → sitemap → image verify</code></pre>\n\n<h2>Frontend: React 19, Vite, and prerender without the memoization circus</h2>\n\n<p>The public app runs on React 19 with Vite. Routes are split so the journal and portfolio chunks do not land on the contact page. The home page loads the hero and trust sections first; heavier bands like the project grid and journal strip wait until the browser is idle. That is not a marketing trick. It is how you keep Largest Contentful Paint from losing to your own enthusiasm for features.</p>\n\n<p>I still write components the old-fashioned way: functions, props, local state where it belongs. I am not importing a state library to toggle a contact form. The React Compiler is on the roadmap for the same reason it matters on client work — less manual memoization, fewer “why did this child re-render” rabbit holes. For now the code stays readable on purpose. If you open the repo, you should recognize patterns you could maintain.</p>\n\n<p>Per-route HTML shells exist for the main public URLs. Crawlers and link previews get real titles and descriptions without running the full client bundle first. Journal posts ship a prerender body in the shell so view-source is not a thin placeholder. In-flow routes like <code>/about</code> and <code>/services</code> get SSR hero markup and critical CSS before hydration. The SPA still hydrates for humans; the shell is there for machines and first paint.</p>\n\n<p>PageSpeed discipline is not an afterthought here. Stylesheet before module script in <code>index.html</code>. Hero LCP gated so the prerender portrait drops only after paint. Public API reads use edge-friendly cache headers — not <code>no-store</code> on every JSON call. When I publish a PSI screenshot on the home page, it is from a run I can reproduce on briancrabtree.me with a date attached.</p>\n\n<h2>Data: PocketBase on the VPS, public API in front</h2>\n\n<p>A mistake I see constantly is wiring the marketing site directly to a database SDK from the browser on every page load. That couples your first paint to a third-party client, your access rules, and whatever latency the backend feels like that day. Here, public pages call <code>/api/public/*</code> routes on a Node process (<code>staging-ai-proxy</code> on <code>127.0.0.1:3005</code>) backed by self-hosted PocketBase.</p>\n\n<p>The CMS instance is <strong>site-pocketbase</strong>: PocketBase listening on <code>127.0.0.1:8092</code>, data on disk at <code>/www/wwwroot/site_pb_data</code>. It is not a managed SaaS add-on. It is a Docker container on the same VPS as nginx and the API proxy. That keeps latency predictable and keeps my content under my control.</p>\n\n<p>Collections that power the public site include:</p>\n\n<ul>\n  <li><code>posts</code> — journal entries (slug, title, summary, HTML body, featured flag)</li>\n  <li><code>projects</code> — portfolio cards and case-study fields (<code>problem</code>, <code>build</code>, <code>outcome</code>)</li>\n  <li><code>testimonials</code> — NDA-safe outcome records, not stock headshots with fake names</li>\n  <li><code>faqs</code> — pricing and process copy for the contact flow</li>\n  <li><code>site_content</code> — hero, footer, announcement bar, system flags as keyed JSON blobs</li>\n  <li><code>messages</code> and <code>form_leads</code> — contact pipeline with attribution</li>\n</ul>\n\n<p>Public routes return sanitized summaries only. Portfolio cards expose stable public IDs via <code>legacy_firebase_id</code> (a migration artifact — the field name stuck, the data lives in PocketBase now). Testimonials on the home page are outcome chips with proof types, not anonymous praise. Likes and view counts adjust through dedicated public endpoints (<code>POST /api/public/like</code>, view increment on read) — not through admin form saves that could accidentally zero engagement.</p>\n\n<p>Representative public endpoints you can curl without credentials:</p>\n\n<ul>\n  <li><code>/api/public/home-posts</code> and <code>/api/public/home-projects</code> — curated lists for the home page</li>\n  <li><code>/api/public/projects</code> and <code>/api/public/posts</code> — full public catalogs</li>\n  <li><code>/api/public/posts/:slug</code> — single journal article</li>\n  <li><code>/api/public/testimonials</code> and <code>/api/public/faqs</code></li>\n  <li><code>/api/public/site-config/:section</code> — hero, footer, announcement bar</li>\n  <li><code>/api/public/system-flags</code> — maintenance mode, WebGL toggle</li>\n</ul>\n\n<p>Contact submissions write to PocketBase and trigger a server notify path. Attribution refs from the URL survive so I can tell which CTA sent the brief. That is boring plumbing, and boring plumbing is what stops leads from dying in a mailbox you forgot to check.</p>\n\n<h2>Admin dashboard: same repo, real operator console</h2>\n\n<p>The admin app is not a separate product bolted on later. It lives at <code>/admin</code> in the same repository, same deployment, same auth assumptions. If you hire me to build you a dashboard, I am extending patterns that already survived me being tired at 11pm before a launch — not guessing what operators want from a whiteboard.</p>\n\n<p>Auth flows through PocketBase: the browser obtains a token via <code>/api/site-auth</code>, then reads and writes through <code>/api/cms/*</code> with that bearer token. Nginx proxies those paths to Node; Node talks to PocketBase on localhost. Admin never opens the PocketBase admin UI on a public port. The attack surface stays small.</p>\n\n<p>The console is organized into five sections:</p>\n\n<p><strong>Dashboard</strong> — Overview shows net traffic, unread lead signals, and ledger totals at a glance. One click publishes <code>sitemap.xml</code> from published PocketBase posts with canonical <code>https://briancrabtree.me/</code> URLs. Sign-in methods (Google, GitHub OAuth) are managed here without a separate settings app.</p>\n\n<p><strong>Site</strong> — This is where most day-to-day content work happens. <em>Blog</em> is a master-detail journal editor: draft or publish, slug, summary, HTML body, header image URL, featured flag, and optional AI assist for expansion or title polish — not a chat widget on the homepage, just operator tooling where it saves time. <em>Projects</em> carries portfolio metadata plus the case-study band fields clients actually read on <code>/projects/</code>. <em>Media</em> uploads to PocketBase file storage. <em>Site content</em> edits hero copy, footer links, and related keyed sections. <em>Announcement</em> toggles the top bar. <em>Testimonials</em> and <em>FAQ</em> maintain the trust and contact-supporting copy without redeploying the SPA.</p>\n\n<p><strong>CRM</strong> — <em>Leads</em> aggregates form submissions with hunter and prospect tooling for outbound research. <em>Inbox</em> is the contact message queue with read state and reply flow. <em>Newsletter</em> manages subscribers and campaign broadcast records.</p>\n\n<p><strong>Ops</strong> — <em>Ledger</em> tracks client invoices (including Stripe-imported paid rows linked to portal users). <em>Calendar</em> syncs Google Calendar availability and client meetings into PocketBase. <em>Kanban</em> boards attach to projects for delivery tracking. <em>Notes</em> is internal scratch space that does not belong on the public site.</p>\n\n<p><strong>System</strong> — <em>Users</em> controls roles and approval for client portal access. <em>Audit log</em> records destructive actions (invoice delete, project save, sitemap publish) with admin email and timestamp. <em>Flags</em> flips maintenance mode, heavy canvas toggles, and hero availability messaging. <em>Gemini Live</em> is an optional voice assist panel for operators who want it — isolated from the public bundle.</p>\n\n<p>Layout follows a master-detail pattern: list on the left, editor on the right on desktop; on mobile, a step UI with command-bar section pills and a single scroll container so long project or journal lists are usable on a phone. Panel actions (Refresh, Save, Publish) surface in the command bar so you are not hunting for buttons below the fold.</p>\n\n<p>Operator discipline matters. Before bulk PocketBase writes or scripted migrations, I run health gates (<code>npm run verify:site-pb-health</code>, <code>npm run audit:site-pb</code>) so empty shell records never pollute the public API again. Scripts that touch posts, projects, or testimonials call a write guard that blocks runs when collections are corrupted. That is the kind of fail-safe you want when agents and humans share the same production database.</p>\n\n<h2>Deploy: making production boring</h2>\n\n<p>Production deploys run through <code>./deploy.sh prod</code>. The script builds the SPA, refreshes per-route shells, syncs <code>dist/</code> to the webroot without deleting unrelated microsites, prunes old hashed assets after a window, regenerates the sitemap from published PocketBase posts, and runs an image URL verification pass. It is unglamorous. That is the point.</p>\n\n<p>Nginx serves static files and proxies API traffic to Node on localhost. SSL and the edge cache sit in front on Cloudflare. Static assets get long cache lifetimes with hashed filenames; HTML stays fresh. For the full story on why a misconfigured <code>Set-Cookie</code> header can defeat your edge cache on JavaScript, see <a href=\"/journal/cloudflare-set-cookie-bypasses-edge-cache/\">Why Cloudflare Refuses to Cache Your JS</a> — I keep that detail in its own post so this one stays focused on stack shape.</p>\n\n<p>When I hand a client a site, they get the same categories of artifact: repository access, deploy notes, analytics hooks, and a clear list of what was measured on the live URL. The reference build is the template for that handoff.</p>\n\n<h2>What you can verify before you hire</h2>\n\n<p>You do not need to take my word for any of this. Open DevTools on the home page and watch the document request and the lazy chunks — you should see <code>/api/public/*</code> calls, not a database SDK initializing on first paint. View source on a journal route and confirm the shell metadata and prerender body. Read the “Verify before you hire” section and run your own Lighthouse pass on briancrabtree.me. Submit the contact form on mobile and confirm the layout does not collapse.</p>\n\n<p>If you have credentials, <code>/admin</code> is where the operator console lives — same stack, same PocketBase collections, same deploy. Without credentials, the public API responses and this article are enough to see how data flows.</p>\n\n<p>For the performance checklist I run before I call a handoff done, see <a href=\"/journal/performance-checks-before-handoff/\">Performance Checks Before Handoff</a>. For lab versus field variance on PSI, start with <a href=\"/journal/why-pagespeed-scores-change-every-run/\">Why PageSpeed Scores Change Every Run</a>.</p>","tags":["react","pocketbase","vite","admin-dashboard","performance"],"views":72}