Case study

A process-and-decisions walkthrough of the CRM we run the studio on.

Vetd CRM

Process notes on building a typed, server-first CRM end-to-end.

Year
2026
Duration
Ongoing
Role
Design, build, ship
Category
SaaS Products
RG
Route groups: marketing site and protected app, one codebase, one deployment.

001

Overview

How we approached it.

We built the studio on Next.js 16, server-first by default, typed end-to-end from the Postgres schema to the UI. This is a walkthrough of the stack, the decisions that shaped the codebase, and the process we used to get from empty repo to running system, without stacking services we didn't understand.

002

Constraints

The four rules we wrote down before the repo existed.

Four constraints shaped every technical decision. We wrote them down before the repo existed and pulled them back out whenever a choice got noisy.

01

End-to-end type safety, no manual sync

If the database schema changes, TypeScript should complain before the browser does. That ruled out any layer that accepts untyped payloads and any client that hand-writes types for server responses. The schema is the contract.

02

Server-first by default

Most pages shouldn't ship JavaScript for content that doesn't need it. React Server Components had to be the default rendering mode, not an opt-in optimization we'd sprinkle in later. Client components exist where interaction actually lives.

03

One codebase, one deployment

Public marketing site and protected app live side-by-side. Route groups handle the split; a single build pipeline ships both. No microservices, no split repos, no shared packages to keep in lockstep, until something earns the split.

04

Maintainable by one developer

Every abstraction had to pull its weight. No state management library until server actions hit a wall. No ORM when generated types already covered it. No framework we didn't already know well enough to debug at 2am.

If the schema changes, TypeScript complains before the browser does.

003

Decisions

Five places where we picked the boring, well-typed option and moved on.

Five decisions that shaped the codebase. Each one a place where we picked the boring, well-typed option and moved on.

01

Next.js 16 App Router, RSC-first

Server Components render by default; client components are the opt-in. Pages read from the database in their own body via async functions. No data-fetching library, no hydration dance for static content. Mutations go through server actions, which revalidate by tag or path and let affected routes re-render themselves.

02

Three Supabase clients, different doors

A browser client for client components, a server client bound to next/headers cookies for RSC and server actions, a middleware client for request-scoped cookie refresh, and a service-role admin client marked 'server-only' so it can't be imported from a client file. Each has a single purpose; picking the wrong one is a type error, not a runtime bug.

03

Generated types, never hand-written

Supabase's CLI generates TypeScript from the live schema into lib/supabase/types.ts. Every query, every mutation, every component imports from that file. A column rename breaks the build across every consumer at once. No drift, no stale fixtures, no guess-and-check.

04

shadcn + Tailwind v4 as the only UI layer

We vendor shadcn components into the repo and edit them directly. Tailwind v4 tokens live in CSS variables (OKLCH) so the design system is themable without rebuilds. No CSS-in-JS, no component library dependency, no runtime style resolution. One PR changes the primary color across the entire app.

05

Vercel Fluid Compute over serverless

Fluid Compute keeps warm instances across requests, so middleware auth and server-rendered pages don't pay cold-start costs the way classic serverless would. Fresh Supabase clients per request (no module-level singletons) means concurrent request isolation still holds up.

TS
Type pipeline: Postgres migrations → Supabase CLI → generated TS → imported everywhere.

004

The stack

Companies, frameworks, and services behind the build.

Next.js

Next.js

App Router, server actions, middleware auth

React

React

RSC-first, Suspense, streaming

TypeScript

TypeScript

Strict mode, generated Supabase types

Tailwind CSS

Tailwind CSS

OKLCH tokens, dark-canonical theming

shadcn/ui

shadcn/ui

Vendored primitives, edited in-repo

Supabase

Supabase

Postgres, Auth, RLS, SSR cookie plumbing

Stripe

Stripe

Hosted Checkout + idempotent webhooks

Cal.comCal

Cal.com

HMAC-signed booking webhooks

Vercel

Vercel

Fluid Compute, edge hosting, image optimization

005

What we wrote

The engineering artifacts that came out of the build.

01

Proxy middleware that refreshes Supabase sessions on every request

02

Three single-purpose Supabase clients (browser, server, admin) with 'server-only' guards

03

Generated Postgres types imported across every query and form

04

Server actions for every mutation, revalidated by tag or path

05

Shared shadcn + Tailwind design system across marketing and app

06

Route groups splitting public marketing and protected CRM, one build

07

React 19 streaming with Suspense boundaries on the slow data paths

08

Dark-canonical OKLCH tokens in CSS variables, themable without rebuilds

006

By the numbers

A quick snapshot of the codebase shape.

Core tables

10

Migrations shipped

35+

Runtime deps

< 40

Monthly tooling

$0

Have a project like this?

Book a 20-minute discovery call. You'll leave with a concrete scope, a timeline, and a price.