
IT Origin
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 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.

The construction industry runs on files that a browser physically cannot open. I built a platform that converts those files once, on the server, into something light, then serves a browser viewer where you can click any beam, door, or wall and read the exact structured data behind it. It's live in production. It runs on hardware I can hold in my hand, not a five-figure cloud bill.
This was a paid client build under a confidentiality agreement with no expiry, so the client, product name, branding, and live URLs stay out of this write-up. What follows is the engineering and design thinking, at the level of "here's the problem I hit and here's how I solved it." That restraint is the point: it's how I'd treat your project too.
Buildings are designed as data now, not just drawings. The open standard for that data is IFC (ISO 16739): one file that carries every wall, pipe, duct, and door, plus the properties attached to each one. A real project's IFC file is fat. Fifty, a hundred, two hundred megabytes of geometry and metadata, sometimes far more.
Here's the part that stings. To open that file, the industry buys desktop software. Per seat. Per year. It runs on Windows, it needs a real GPU, and it locks your building data inside one vendor's format. A client on site with a tablet? A structural reviewer who just wants to check one connection? An owner who paid for the building and wants to look at it? They're all stuck asking someone with the right license to export a screenshot.
Four pains stack up: proprietary lock-in, desktop-only tools, no real browser collaboration, and no automated way to check whether a model is even correct. That's the wall I was hired to knock down.
The catch: you cannot just throw a 200MB IFC file at a browser tab and hope. The browser will choke, the tab will die, and the user will leave. So the interesting question was never "can I render a building?" It was "where does the heavy work happen, and what actually reaches the browser?" That question decided the whole architecture.
One call shaped the entire system: heavy IFC files get converted exactly once, on the server, into two lightweight outputs. The browser only ever loads those.
The browser never touches raw IFC. Ever. That's not a preference, it's an invariant I enforced in code and never broke.
Once that call is made, the good problems appear. If conversion is expensive, you want to do it as few times as possible. So every uploaded file is hashed with SHA-256, and the hash is the cache key. Upload the same building twice, from two different people, and it converts zero extra times. Convert once, cache forever. That single idea keeps compute costs frugal, which matters a lot when you're not sitting on an infinite cloud budget.
Here's the pipeline, because this is the spine of everything:
Notice what's not in Postgres: the big geometry blobs. Metadata, ownership, and relationships live in the database where they can be queried and joined. The heavy GLB and JSON artifacts live in blob storage, linked by hash. Stuffing multi-megabyte binaries into database rows is how you get a database that's slow to back up and expensive to scale. I kept them apart on purpose, and the storage layer is swappable to S3-style object storage later with no schema change.
IFC conversion looks like one job. It's two.
Getting accurate 3D geometry out of IFC is a hard computational-geometry problem. The best open-source tool for it is IfcConvert, built on IfcOpenShell and the OpenCASCADE kernel. It turns IFC solids into clean glTF. What it is not good at is handing you the structured data model: the property sets, the spatial hierarchy, the relationships.
For that, there's web-ifc, a fast IFC parser that reads the schema, walks the Project to Site to Building to Storey tree, and enumerates which elements live in which space.
I could have tried to force one tool to do both. That's the trap. Instead I drew a hard line: the geometry engine owns geometry, the data engine owns data, and neither reaches into the other's job. Two engines, one orchestrator, clean seams between them. When something looks wrong in a model, I know exactly which engine to interrogate. That boundary saved me hours of debugging I'll never get to count because it never happened.
This is the part I'm proudest of, and it's invisible when it works.
You click a wall in the 3D view. The viewer should tell you: this is element #26, an IfcWallStandardCase, named "Wall," with this exact global ID. Simple, right? Click, get data.
It is not simple. Here's the trap I walked straight into.
When the geometry engine writes glTF, the mesh you click is named after the shape representation, the geometry, not the element it belongs to. So the obvious approach, "read the name of the mesh I clicked and look it up," gives you the wrong ID. Confidently wrong. The kind of wrong that ships, passes a casual demo, and then blows up the first time a real engineer trusts it to check a real connection.
The fix took understanding the file format at the byte level. The element identity lives one level up, on a parent node named product-{id}. And the id in that node name is a GlobalId that's been expanded into a specific UUID form that the geometry engine writes. So the real resolution path is:
raycast the click ──▶ find the hit mesh ──▶ walk UP the scene graph
to the product node ──▶ read its expanded GlobalId
──▶ look up the element_map ──▶ the correct Express ID + type + nameI pinned the GlobalId-to-UUID expansion against real converter output with a golden test, so if the tool ever changes its naming, a test screams before a user ever sees a wrong answer.
I later made it survive things that had no right to work: it stays correct while you're walking through the building in first-person, and it stays correct when the model is exploded into its separated parts and every element sits at a transformed position. I proved that with headless tests that pick at the new projected location and confirm the right element, then pick at the vacated spot and confirm nothing. When the invariant has to hold under motion, you test it under motion.
A 50MB production model has thousands of individual pieces. Naively, every piece is a draw call, and thousands of draw calls per frame is how you get a slideshow instead of a viewer.
The lever that mattered most was geometry batching. By merging compatible geometry into a BatchedMesh, the draw calls on that benchmark model collapsed from around 3,463 down to 584 in Chrome. Same building, a fraction of the per-frame overhead. Isolating a single storey drops it further, from 542 draws to 219, because the viewer stops drawing floors you're not looking at. Big element lists get virtualized so the DOM renders what's on screen, not the whole tree. Raycasting gets a BVH acceleration structure so click-picking stays instant even on dense models.
Now the honest part, because a case study without a "what went wrong" is a brochure.
The batching win is real, and it's mostly a Chrome win. When I benchmarked the exact same model in Firefox, the draw calls stayed at the unbatched ~3,467 and the frame rate sat around 18 FPS, under the bar I wanted. The reason is specific: BatchedMesh leans on a WebGL feature called WEBGL_multi_draw, and on that browser plus that driver stack it wasn't there, so the engine submitted each geometry individually. I didn't hide that. I wrote it down in the runbook with the fallback lever for later (feature-detect, and merge into one buffer per material when the fast path is missing).
I also built a zero-touch way to measure this. Add a couple of query params to the URL and the camera auto-orbits the loaded model while a heads-up display reports median, average, and 5%-low frame rate. Anyone can capture a clean performance number by opening a link and waiting twenty-five seconds. No profiler, no ceremony. That mattered because the client needed to run the same measurement on their own workstation GPU, and "open a URL, read four numbers" is a test a non-engineer can run.
Here's the thing nobody tells you about tools for technical people. The instinct is to make them look "professional," which usually means busy: gradients, accent colours, badges shouting for attention. I went the other way.
The whole interface is monochrome. Off-white canvas, ink text, hairline borders, generous whitespace. The single accent colour is the same near-black as the buttons. There is exactly one source of colour on the screen, and it's the 3D model. The building is the hero. Everything else gets out of its way.
That's not just taste, it's a system with rules that make it cheap to maintain and swap. This is the same argument I make for a design system that earns its keep, applied to a tool where calm is the feature:
Restraint was the hardest design discipline to hold and the one I'm most sure about. A coordination tool earns trust by being calm.
The processing service is a Hono app on Bun, and it follows one rule that made it a pleasure to test: routes are thin, logic is layered, and every external dependency is an injectable seam.
The conversion pipeline is split into small modules (hash, data-engine, geometry-engine, persistence, orchestrator). The engine binary, the database, and the filesystem are all passed in, not reached for. Because of that, the entire unit suite runs with no converter binary installed and no database connected. The tests exercise the real logic against fake seams, and the integration tests that genuinely need the binary are gated on an environment variable so they skip cleanly on a machine that doesn't have it. That's the difference between a test suite people run and one they route around.
A few more backend decisions worth naming:
I'm opinionated about this one. Most bugs I see in web apps are shape mismatches: the database returns one thing, the UI expects another, and nobody finds out until production.
I closed that gap with an unbroken type spine. The database schema is defined in Drizzle. Zod validates every boundary. TanStack Start's server functions carry the inferred types across the network to typed React components. There is no any in the codebase; genuinely unknown values are typed unknown and validated at the edge with Zod before anything trusts them. That's one language across the whole stack earning its keep.
The payoff is concrete: change a database column, and TypeScript flags every affected spot in the UI at compile time. Not at 2am in production. At compile time, in my editor, before I've even saved. I evaluated adding tRPC on top for the RPC layer and deliberately cut it, because the server functions already gave me end-to-end typed calls and tRPC's mount needed an unreleased framework API. Fewer moving parts, same guarantee. Knowing what to leave out is half the job, and it's a big part of why I reached for TanStack Start here.
This wasn't a prototype. The viewer grew into a real coordination tool set, each piece built the same disciplined way:
| Capability | What it does |
|---|---|
| Click-to-identify | Select any element, get its true Express ID, type, name, and global ID |
| Hover identity | A cursor-following label with the element's identity, stabilized so it doesn't flicker |
| Element navigator | A grouped, searchable, virtualized list wired both ways to the 3D selection |
| Visibility control | Hide, isolate, and ghost elements; isolate a single storey |
| Storey levels | Derive floor planes and labels from geometry; isolate the union of chosen floors |
| Section box | Clip the model with a draggable box and plane gizmos, right in the 3D view |
| Measurement | Distance, angle, and multi-point path, with vertex/edge snapping and a saved list |
| First-person walk | Drop to eye height and walk the building with WASD, look with the mouse |
| Explode view | Separate every part radially along a slider, non-destructively, then reassemble pixel-identical |
| Named viewpoints | Save and restore camera, visibility, section, and selection, shaped for a future BCF export |
| Screenshot export | Download the exact current frame as a PNG |
| Orientation cube | A corner ViewCube that mirrors and snaps the camera |
Under all of it: a properties panel, a spatial-tree panel, a mobile layout where the panels become drawers so the model owns the screen, login-gated access with role-based permissions, and a public demo model anyone can open without an account.
The infrastructure is deliberately frugal. Both apps run as Docker images on a self-hosted platform on a single modest VPS, auto-deploying from the main branch through a push webhook that only rebuilds the app you touched. PostgreSQL with pgvector (the vector column is already there for a future AI assistant that answers questions grounded in the model's own data), Redis provisioned for the job queue, TLS from Let's Encrypt. No managed-service sprawl, no surprise invoice. That's the web app built like an appliance: doing more with less isn't a slogan here, it's the deployment.
Three stories, because the scars are where the learning is.
The disappearing 3D. After one change, the viewer rendered nothing. Blank. The model was loading fine, selection still worked, no errors in the console. The cause was a rendering subtlety: an extra render pass ran with the engine's default "clear the screen" behaviour still on, so every frame drew the building and then immediately wiped it. Selection kept working precisely because click-picking reads geometry, not pixels, which is what made it so confusing. I proved it wasn't a screenshot artifact by painting the canvas red with a probe, then fixed the pass to stop clearing. Then I found the same class of bug in two other places and fixed those too.
Fullscreen is a second universe. Tooltips vanished in fullscreen. Turns out fullscreen creates a separate rendering context, and UI that portals to the document body ends up behind the fullscreen layer. The fix was to route every overlay into the fullscreen host. The meta-lesson, which I wrote into the project's standards so it never bites again: in fullscreen, every DOM overlay needs an explicit container, and every WebGL pass needs to scissor or disable its clear.
The pick offset that wouldn't die. For a stretch, clicks resolved slightly off from where the cursor actually was. It's the bug I chased hardest, because "close enough" is worse than "obviously broken" in a tool people trust for coordination. The root cause hid in the hot path: the pick logic was quietly re-aiming the camera on hover, feeding a tiny drift back into itself. Reading the render loop line by line, out loud, beat guessing at it. Temporal bugs (flicker, jump, drift) don't yield to reasoning from the outside; you have to sit in the frame loop and watch.
None of these are embarrassing. They're the texture of building something real at the edge of what browsers do.
Worth saying, because process is a deliverable too.
Most features went brainstorm to spec to plan to test-driven build, with a browser "definition of done" at the end: the feature isn't finished when the tests pass, it's finished when I've driven it in a real browser on the 50MB benchmark and watched it behave, with zero console errors. Four hundred plus headless unit tests, green on every merge. Strict typecheck, clean lint, and a green build as hard gates, no exceptions. Cross-browser checks in CI.
Pure logic gets extracted and tested directly; the framework glue stays thin so the real thinking is testable without a GPU. That's why a viewer this feature-heavy has a test suite that runs in seconds and a bug rate that stays low.
If I ran it again, I'd wire the geometry-batching fallback for the non-multi_draw browsers earlier, instead of proving the win on one engine first and documenting the gap. It's the one place where "measure, then optimize" cost me a second pass.
What's already scaffolded for the next phases: the vector column is sitting in the database waiting for a model-grounded AI assistant of the kind that ships instead of demos, the viewpoints are shaped for a real BCF coordination-issue export, and the storage layer is one adapter swap away from object storage when the models get bigger than one disk. The foundation was built to carry the intelligence, not just the pixels.
Off-the-shelf viewers show geometry. The hard requirement here was that a click resolve to the correct structured element with total reliability, because validation, requirement-checking, and an AI layer all depend on that mapping being trustworthy. Owning the conversion pipeline and the pick resolution end to end was the only way to guarantee it.
A real project's IFC file is tens to hundreds of megabytes. Parsing that in a browser tab is slow at best and a dead tab at worst. Converting once on the server to lightweight glTF plus JSON, then caching by content hash, means the browser does the light work and each unique file is processed exactly once.
Geometry batching to cut draw calls, storey isolation to stop drawing floors you're not looking at, list virtualization for large element trees, and a BVH to keep click-picking instant. On the benchmark model, batching alone cut draw calls from around 3,463 to 584 in Chrome.
Yes. Both the web app and the processing API are live, auto-deploying from the main branch onto self-hosted infrastructure, with HTTPS, a login-gated app, role-based access, and a public demo model that opens without an account.
Not without their written consent. The engagement is under a confidentiality agreement with no expiry, so I show the craft and keep the identity sealed. If you're evaluating me and need a reference, that's a conversation I can have privately, with the client's permission.
The interesting work wasn't rendering a building. It was deciding where the heavy work lives, making a click tell the truth every single time, and holding enough design restraint that the tool feels calm instead of loud. Constraints did the shaping: a browser that can't parse the files, a budget that rewards frugal compute, an NDA that rewards discretion. Good constraints make better products, and they make sharper builders.
If you're building something where the browser has to do a desktop's job, or where a click has to be exactly right because people will make real decisions on it, tell me what you're wrestling with. I'll tell you honestly whether I'm the right person to 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 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 showroom tablet that keeps selling when the internet quits