
A Whole Building in a Browser Tab
A browser-native BIM platform: heavy IFC building models converted on the server, so anyone can click a wall in the browser and read the exact engineering data behind it. No install, no plugin, no license.
A cybersecurity firm's whole web presence in one self-hosted Next.js codebase: marketing site, admin CMS, lead engine, and an AI chat desk

A cybersecurity company sells trust. If their own website loads slow, breaks on hydration, or ships a 56 MB video to a phone on 4G, the pitch is dead before the first sentence. So the brief was never "make a pretty site." It was "build the machine that turns a visitor into a booked call, and give the team the keys to run it without calling a developer every week."
That second half is where most agency builds quietly fail. They ship a beautiful front end wired to a CMS the client is scared to touch. I wanted the opposite: a front end fast enough to disappear, and a back office the marketing lead could drive on a Tuesday morning with a coffee and zero training. That's the difference between building for the user and building for the buyer.
IT Origin had a service to sell and no engine to sell it. What they were missing broke down into four gaps.
They had no way to publish. Every blog post, case study, or event meant emailing a developer and waiting. Content that should ship in an hour took a week, so it mostly never shipped.
They had no way to capture. Visitors landed, read, and left with no trace. No lead record, no newsletter list, no follow-up. Attention walked in the front door and out the back.
They had no way to answer. A security buyer researching at 11 pm has questions, and a contact form that replies "we'll get back to you in 2 business days" loses them to a competitor who answered in ten seconds.
And they had no way to run any of it themselves. A marketing site that only a developer can edit is not an asset. It is a recurring invoice.
Success looked like this: the team ships content without me, leads land in a place they can act on, the buyer gets answers in real time, and the whole thing loads fast enough that Google and a distracted human both stick around.
I do not pick tools because they trend. I pick them because they remove a specific pain I have felt at 2 am. Here is the short version, then the reasoning that matters.
| Layer | Choice | The one-line reason |
|---|---|---|
| Framework | Next.js 16 (App Router) | Server Components mean the heavy work happens before the page reaches the phone |
| Runtime / PM | Bun | Installs and dev server are fast enough that I stopped noticing them |
| Language | TypeScript, strict, zero any | Errors surface at compile time, not in a client's inbox |
| Database | PostgreSQL + Drizzle ORM | The query types come from the schema, so a wrong column is a red squiggle, not a production 500 |
| Auth | Better Auth | Sessions, roles, and email/password without renting a third-party identity bill |
| Styling | Tailwind v4 (OKLCH) | Design tokens live in CSS, colors stay perceptually even across the theme |
| Editor | Tiptap | A real rich-text surface the content team controls, not a raw textarea |
| Resend + React Email | Emails written as components, so they match the brand instead of looking like a receipt | |
| AI chat | Google Gemini via the AI SDK | Streaming answers grounded in a system prompt about the actual company |
| Rate limiting | Upstash rate limit over Redis | The contact form and chat verify endpoints do not become a spam faucet |
| Uploads | Vercel Blob | Image storage that does not need a separate S3 setup to babysit |
Two of those deserve more than a line.
Drizzle over a heavier ORM. Picture the schema as the single source of truth. You write the table once, and Drizzle infers the exact shape of every row, every insert, every join. Change a column from nullable to required, and the compiler walks you to every place that assumption now breaks. I have shipped apps where a renamed field slipped through and surfaced as a null-pointer three weeks later in front of a client. With inferred types, that class of bug just stops existing. The queries read like SQL because they basically are SQL, so there is no fat translation layer guessing what I meant. This is one language across the whole stack doing real work.
Bun as the runtime, not just a party trick. The frugal builder in me hates waiting. Bun made the install-test-repeat loop fast enough that I ran the full production build after almost every meaningful change instead of "later." That habit is why the perf and SEO problems later in this document got caught in minutes, not after a client complaint.
I gave things up, too. Self-hosting on Coolify means I own the uptime. No Vercel edge network catching a bad deploy for me. I took that trade on purpose, and I will explain the payoff when we get to infra.
Server Components are the backbone. Think of a restaurant kitchen. The prep happens out of sight, the diner only ever sees the finished plate. Most of this site renders on the server, ships as HTML, and only reaches for client-side JavaScript where a human actually needs to click, type, or watch something move. Default to server. Add "use client" only when interaction demands it. That single rule keeps the JavaScript the browser has to parse small, and small JavaScript is fast JavaScript. (I unpack that metaphor properly in React Server Components explained like a kitchen.)
The app splits into three worlds by route group, each with its own layout:
(marketing) wraps the public pages in the topbar, header, chat widget, and footer. This is the shop window.(auth) is a stripped, centered card for sign-in and sign-up. Nothing to distract from the one action.admin is the back office: sidebar, header, and every management screen behind a role check.Security runs in two layers, and I kept both on purpose. The edge proxy (Next.js 16 renamed the middleware file, same idea) checks one cheap thing before any admin request even reaches the server: is there a session cookie? No cookie, you bounce to sign-in. That is the bouncer at the door. Then every single admin API route and admin page checks the real question again on the server: is this user actually an admin? That is the manager verifying the badge inside.
The API follows one boring, predictable shape, and boring is the point. Every resource gets /api/admin/{resource} for list and create, /api/admin/{resource}/[id] for read, update, delete. Special actions, like sending a campaign, get their own explicit endpoint. When the pattern never surprises you, you stop thinking about plumbing and start thinking about the feature.
The schema is not one giant file. It is 13 focused modules, each owning a slice of the business: auth, blog, leads, services, chat, email, settings, resources, case studies, testimonials, events, appointments. They export through a single index, so the rest of the app imports types from one clean door.
Fourteen migrations track how the model grew over time. Nothing gets pushed to a schema by hand in production. The build emits a standalone server, and the container runs the migrations on startup before it serves a single request. So a deploy that changes the database applies the change automatically, in order, every time. No "did someone run the migration?" Slack message at midnight.
One small discipline that pays off quietly: raw SQL in the API routes uses parameterized queries, the $1, $2 style, not string interpolation. On a pooled connection that is both safer against injection and kinder to the query planner. It costs nothing to do and saves you from the one bug you really do not want on a security company's website.
Here is the thing nobody tells you about design systems: consistency is not about picking nice colors. It is about making the wrong choice impossible. If a developer can type any hex code, they will, and six months later you have forty shades of "brand red." So the color lives in one place, as a token, and everything references the token. That's the whole argument for a design system that earns its keep.
The palette is built in OKLCH, not hex or HSL. That matters more than it sounds. OKLCH is perceptually even, so when the brand's signature high-chroma red (oklch(0.637 0.237 25.331), a red that means business) shifts between light and dark mode, it stays the same felt red instead of drifting muddy or neon. Every semantic color, background, foreground, primary, accent, muted, card, sits behind a CSS variable. Dark mode is not a second stylesheet. It is the same tokens with different values, flipped by a class on the root.
Typography carries two jobs. Satoshi for headings, so the loud moments feel sharp and deliberate. Inter for body, because you read a lot of it and it should get out of the way. The components are shadcn/ui primitives built on Radix, which means the accessible behavior (focus traps, keyboard nav, ARIA wiring) is handled correctly underneath, and I get to style the surface without rebuilding a dropdown from scratch for the hundredth time.
Motion comes from Motion (the library formerly styled as Framer Motion), with a small set of shared variants: fade-in-up, stagger, and a few friends. Reused, not reinvented per page. Movement should feel like the same hand drew it everywhere.
And the copy itself is not trapped in JSX. Page text lives in a data layer under utils/data, organized per page. When marketing wants to reword the hero, that is a content edit in one file, not a scavenger hunt through components. Small decision, big difference on a site this size.
This is where the "whole company online" claim earns itself. The build is not a brochure with a contact form bolted on. It is a set of working systems.
A real content CMS. Blog posts with a Tiptap rich-text editor, categories, tags, comments, draft and published states, cover images, reading time, view counts, and slug-redirect handling so an old URL never 404s after a rename. The team writes, previews, and ships. No developer in the loop.
A lead and growth engine. Contact submissions become lead records. A newsletter with double opt-in (subscribe, confirm by token, welcome), unsubscribe by token, and CSV export for the sales team. Campaigns you compose in the admin and send to the list, with a monthly newsletter that goes out on a schedule.
An appointment system. Bookings with a full lifecycle: pending, confirmed, completed, cancelled, no-show. The team runs their calendar from the same dashboard they run everything else.
An AI chat desk. A support widget backed by Google's Gemini, streaming answers grounded in a system prompt about IT Origin's actual services. It verifies a visitor's email before a real conversation, and every conversation is stored so the team can review what buyers are asking. This is the "answer at 11 pm" gap, closed, and it's the frugal kind of AI that ships instead of AI that demos.
The rest of the shop. Case studies, testimonials with an approval flow, events and webinars, downloadable resources and gated whitepapers with a content-derived table of contents, and a services section that builds itself from the database so the navigation updates when the offerings do.
Every one of those has an admin screen, an API, and a schema behind it. Twenty-two admin screens is not padding. It is the difference between a site the client owns and a site the client rents from me forever. I would rather they own it.
Here is where I break the fourth wall, because the case study rule I hold myself to is simple. Show what went wrong. Polished success stories build less trust than an honest post-mortem, and I have one.
I ran a full SEO and performance audit on the live site. It came back a B minus. Two categories scored an F. Not the client's fault. Mine. A site is a living thing, and things I let slide during the sprint had piled up. So I fixed them, measured every change, and the numbers below are all real, taken from this codebase and the live server.
The homepage hero was a rotating video carousel. Gorgeous. Also quietly catastrophic. All three background videos mounted at once, every one set to autoplay, none told to wait. So a browser hitting the page eagerly pulled all three: 3.6 MB, then 33 MB, then 19 MB. Roughly 56 MB of video before the visitor read a single word. On a phone, that is somebody's data plan and their patience, gone.
Two separate problems hid inside that one symptom, and telling them apart was the whole fix. The first was a loading problem: three videos in the DOM when only one is on screen. The second was a weight problem: a 33 MB file for an eight-second loop that sits behind an 80 percent black overlay, where nobody can see the detail you paid all those megabytes for.
So I fixed both. Only the active slide now mounts a video. The others rest as a lightweight poster image, which doubles as the instant first paint instead of a black rectangle. Then I re-encoded every clip to a web-sane 720p, stripped the audio nobody hears on a muted background, and moved the file's index to the front so playback starts before the download finishes.
| Asset | Before | After |
|---|---|---|
| Hero videos, total | 56 MB | 7.7 MB (down 86%) |
| Videos fetched on first paint | 3 (all) | 1, behind a ~100 KB poster |
| Dead weight removed | 24 MB of videos referenced nowhere | deleted |
This one is my favorite, because it is invisible until you go looking. The site had two sitemap generators. A modern Next.js route that correctly used the real domain, and an older tool that wrote a static file at build time using an outdated domain. In Next.js, a static file in the public folder wins over a generated route at the same path. So the stale one was serving. For weeks, search engines were being handed a sitemap pointing at the wrong domain, and nobody noticed because both files "existed."
The audit flagged a JavaScript error. The browser console was clean. That contradiction is exactly why it survived so long. It was a React hydration mismatch, and it came from the most innocent line in the codebase: a footer that rendered today's date with toLocaleDateString(). The server formats that date in its timezone. The browser formats it in yours. Different text, same spot, and React throws a fit that shows up as a page error but not a console log. Gone now, and I swept the rest of the site for the same trap.
Twelve pages pointed their social share image at files that did not exist. So every link shared on LinkedIn or WhatsApp showed up bald, no picture. Instead of drawing twelve images by hand and maintaining them forever, I built a single generator route that renders a branded card on demand, takes the page title as a parameter, and stamps the trust badges (CERT-In, ISO 27001, STQC) straight from config. One route, every page covered, nothing to keep in sync.
| What the audit dinged | Before | After |
|---|---|---|
| Structured data (schema for AI and search) | none live | 5 types, site-wide |
| Broken booking link | 404 | removed, native flow |
| Sitemap and robots domain | split, wrong domain served | single, correct domain |
| Meta description | too short | rewritten with the keywords that were missing |
| Hero page weight | 4+ MB | slashed with the video work above |
| Hydration error on load | 1 | 0, verified on a real build |
| Broken social images | 12 | 0, generated on demand |
I also wired up analytics the right way, gated behind cookie consent, so the team can finally measure instead of guess. The instrument is in. The reading comes next.
The build compiles to a standalone server, packed into a multi-stage Docker image on a Bun base. The container's entrypoint runs the database migrations first, then boots the server, so schema changes ship with the code and apply themselves. Coolify on a VPS handles the deploy, watches the main branch, and rebuilds on merge. A local Docker Compose file gives me Postgres and Redis for development that mirror production without me installing anything on the metal.
I chose self-hosting with eyes open. The trade is real: no managed edge network to catch my mistakes, so my build discipline has to be the safety net instead. The payoff is control and a flat, predictable cost instead of a bill that scales with a viral week. For a company that plans to publish steadily for years, owning the box beats renting the magic. It's the same instinct as building the web app like an appliance: predictable, owned, and boring in the ways that count.
Honesty is the whole point of this section, so here it is.
I would run the audit first, not after the sprint. Every problem in that performance chapter was avoidable at the moment I wrote the code. The 56 MB hero was a decision I made and did not measure. Catching it later cost more than catching it early would have. Measure the thing while your hands are still on it.
I would collapse duplicate systems the day I notice them. The two-sitemap mess grew because "both work" felt fine at the time. It was not fine. It was a quiet bug with a fuse.
What is next is mostly instrumentation paying off. Analytics is live now, so the next round of decisions gets made on real visitor behavior instead of my best guess. The AI chat logs are a goldmine of the exact questions buyers ask, which is free product research the sales team can mine. And there is a short punch list I handed back to the client: connect the analytics ID, finish the booking flow on their own platform, and put a CDN in front for the last slice of speed.
Most marketing sites are a pretty face on a fragile body. This one is the other way around. The face is fast because the body does the heavy lifting on the server. The team owns their own content because the CMS was built for them, not for me. The buyer gets answers at midnight because the chat desk never sleeps. And when the site started slipping, I could measure exactly where and fix it in an afternoon, because the whole thing is typed, tested by build, and honest about its own state.
That is the standard I hold every project to. Not "does it look good in a screenshot," but "does it still work when the traffic spikes, the team edits it on a Friday, and the founder checks the analytics on a Monday." This one does.
If you are building something where the website is not decoration but the actual engine of the business, that is the kind of work I like most. Tell me what you are building, and I will tell you honestly how I would build it.
Prabir Singh
Full-stack engineer from Mumbai. Twenty years in sales, now I build products that sell. More about me or start a project.

A browser-native BIM platform: heavy IFC building models converted on the server, so anyone can click a wall in the browser and read the exact engineering data behind it. No install, no plugin, no license.

A showroom tablet that keeps selling when the internet quits