Chapter 2: Tech Stack Overview
Every technology in this stack was chosen for a specific reason. This chapter explains what each one does, why it earned its place, and how they compose together into a cohesive architecture.
The Build Layer: Vite
What it is: A build tool and development server that leverages native ES modules for near-instant hot module replacement (HMR).
Why Vite:
- Speed: Dev server starts in milliseconds regardless of project size. HMR updates are reflected in under 50ms. This is not marketing — it is architecture. Vite serves source files directly as ES modules during development, skipping the bundling step entirely.
- Native ESM: Vite uses the browser's native module system during development. Only production builds go through Rollup for optimization.
- Plugin ecosystem: First-party plugins for React (
@vitejs/plugin-react), Tailwind CSS (@tailwindcss/vite), and a rich community plugin ecosystem. - Configuration: A single
vite.config.tsfile handles aliases, plugins, server options, build options, and test configuration (when using Vitest). - Standards-based: Vite aligns with web standards rather than inventing proprietary module systems.
What it replaces: Create React App (deprecated), Webpack (slow), Parcel (less ecosystem).
// vite.config.ts — a taste of how clean it is
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
export default defineConfig({
plugins: [
TanStackRouterVite(),
react(),
tailwindcss(),
],
resolve: {
alias: { "@": "/src" },
},
});
The UI Layer: React
What it is: A library for building user interfaces through composable components.
Why React:
- Component model: Declarative components with hooks provide a clean mental model for building UIs.
- Ecosystem: The largest ecosystem of libraries, tools, and community knowledge of any UI framework.
- Concurrent features: React 18+ provides
useTransition,useDeferredValue, andSuspensefor keeping UIs responsive during heavy computation. - Stability: React has a strong backward compatibility track record. Code written for React 16 largely works in React 19.
- Hiring: React is the most widely known frontend framework, which matters for team building.
What it is not: React is not a framework. It does not include routing, data fetching, form handling, or state management. Our stack provides those through dedicated, best-in-class libraries.
The Router: TanStack Router
What it is: A fully type-safe, file-based router for React applications.
Why TanStack Router over React Router or Next.js:
- 100% type-safe: Every route path, parameter, search parameter, and loader is fully typed. Navigate to a route that does not exist? TypeScript catches it at compile time. Pass the wrong type to a search parameter? Compile error.
- File-based routing: Routes are defined by file system conventions, providing automatic code splitting and a clear mental model.
- Search parameter management: First-class support for type-safe URL search parameters with validation, default values, and serialization.
- Built-in data loading: Route
loader()functions fetch data before rendering, with integration into TanStack Query for caching. - No vendor lock-in: TanStack Router is a library, not a platform. Deploy anywhere.
// src/routes/projects/$projectId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod"; // or Effect Schema
export const Route = createFileRoute("/projects/$projectId")({
// Params are fully typed — $projectId is inferred as string
parseParams: (params) => ({
projectId: params.projectId,
}),
// Search params are validated and typed
validateSearch: z.object({
tab: z.enum(["overview", "tasks", "settings"]).default("overview"),
page: z.number().default(1),
}),
// Loader runs before render, data is typed
loader: async ({ params, context }) => {
return context.queryClient.ensureQueryData(
projectQueryOptions(params.projectId)
);
},
component: ProjectPage,
});
The Server State Layer: TanStack Query
What it is: A data synchronization library that manages server state — fetching, caching, synchronizing, and updating data from your API.
Why TanStack Query:
- Automatic caching: Data is cached by query key and automatically refetched when stale. No manual cache management.
- Background updates: Data refreshes in the background while showing cached data, keeping the UI responsive.
- Optimistic updates: Update the UI immediately and roll back if the server rejects the change.
- Request deduplication: Multiple components requesting the same data share a single network request.
- Declarative invalidation: When you mutate data, declare which queries are now stale. TanStack Query handles the refetch.
- Infinite queries and pagination: Built-in support for paginated and infinite-scroll data patterns.
// Using TanStack Query with Effect
function useProjects(organizationId: string) {
return useQuery({
queryKey: ["projects", "list", organizationId],
queryFn: () =>
Runtime.runPromise(
ProjectService.list(organizationId).pipe(
Effect.provide(LiveLayer)
)
),
staleTime: 30_000, // Consider fresh for 30 seconds
});
}
The Architecture Layer: Effect
What it is: A comprehensive TypeScript framework for building robust applications. Think of it as the missing standard library for TypeScript — providing typed errors, dependency injection, schema validation, concurrency, and more.
Why Effect is the backbone of our architecture:
Typed Errors
Traditional TypeScript has no way to express what errors a function can throw. Effect makes errors part of the type signature:
// The type tells you EVERYTHING: succeeds with User, can fail with
// UserNotFound or DatabaseError, requires UserRepository
type GetUser = Effect.Effect<
User,
UserNotFoundError | DatabaseError,
UserRepository
>;
Dependency Injection
Effect provides compile-time verified dependency injection through Services and Layers. No runtime container, no decorators, no reflection:
// Define what a service looks like
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect<User, UserNotFoundError>;
readonly create: (data: CreateUserData) => Effect.Effect<User, ValidationError>;
}
>() {}
// Use it — the type system tracks the dependency
const getUser = (id: string) =>
Effect.gen(function* () {
const repo = yield* UserRepository;
return yield* repo.findById(id);
});
// Type: Effect<User, UserNotFoundError, UserRepository>
Schema Validation
Effect Schema provides a single source of truth for types AND runtime validation:
import { Schema } from "effect";
const CreateProjectSchema = Schema.Struct({
name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100)),
description: Schema.optional(Schema.String),
organizationId: Schema.String.pipe(Schema.brand("OrganizationId")),
});
// Infer the TypeScript type from the schema
type CreateProject = typeof CreateProjectSchema.Type;
// { name: string; description?: string; organizationId: string & Brand<"OrganizationId"> }
Concurrency and Resilience
Built-in retry logic, circuit breakers, rate limiting, timeouts, and structured concurrency through fibers:
const resilientFetch = pipe(
HttpService.get("/api/data"),
Effect.retry(Schedule.exponential("1 second").pipe(Schedule.compose(Schedule.recurs(3)))),
Effect.timeout("10 seconds"),
);
What it replaces: Scattered try/catch blocks, manual dependency wiring, Zod (for validation), custom retry logic, ad-hoc error types.
The Data Layer: Prisma
What it is: A TypeScript-first ORM that generates type-safe database clients from a declarative schema.
Why Prisma:
- Schema-first: Define your data model in a human-readable schema file. Prisma generates TypeScript types, database migrations, and a query client automatically.
- Type-safe queries: Every query is fully typed. Prisma knows which fields exist, which relations can be included, and what the return type will be.
- Migrations: Prisma Migrate generates SQL migration files from schema changes, providing a versioned, reviewable history of database evolution.
- Multi-database support: Works with PostgreSQL, MySQL, SQLite, SQL Server, and MongoDB.
- Prisma Studio: A visual database browser for development.
// prisma/schema.prisma
model Project {
id String @id @default(cuid())
name String
description String?
status ProjectStatus @default(ACTIVE)
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([status])
}
enum ProjectStatus {
ACTIVE
ARCHIVED
COMPLETED
}
The Type System: TypeScript
TypeScript is not listed as a "technology choice" because it is not optional. It is the substrate that makes everything else work. Our TypeScript configuration is strict:
// tsconfig.json (key settings)
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true, // Required for Effect
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Every library in our stack is TypeScript-first. Type safety flows from the database schema through Prisma, through Effect services, through TanStack Router, through React components, all the way to the DOM.
The Styling Layer: Tailwind CSS v4 + shadcn/ui
Tailwind CSS v4
Why: Utility-first CSS that eliminates naming and specificity problems. Version 4 brings CSS-native configuration, 3.78x faster builds, and first-party Vite plugin support.
/* app.css — Tailwind v4 configuration is CSS-native */
@import "tailwindcss";
@theme {
--color-primary: oklch(0.55 0.2 250);
--color-primary-foreground: oklch(0.98 0 0);
--font-sans: "Inter", sans-serif;
--radius-lg: 0.75rem;
}
shadcn/ui
Why: Unlike traditional component libraries (Material UI, Ant Design, Chakra), shadcn/ui copies component source code into your project. You own every line. No version conflicts, no fighting the library's opinions, no bundle bloat from unused components.
- Built on Radix UI primitives (accessible, headless)
- Styled with Tailwind CSS (consistent, customizable)
- Full TypeScript support
- Dark mode out of the box
- 65,000+ GitHub stars and used by Vercel, Supabase, and major production apps
The Testing Pyramid
Vitest (Unit + Integration)
Vite-native test runner. Shares your Vite config, understands your aliases, and runs in the same module system as your app. Jest-compatible API means zero learning curve.
React Testing Library (Component)
Tests components the way users interact with them — through rendered output, not implementation details. getByRole, getByLabelText, userEvent for realistic interaction simulation.
Playwright (End-to-End)
Cross-browser E2E testing with auto-waiting, trace recording, and screenshot capture on failure. Tests run against a real browser, catching integration issues that unit tests miss.
The DevOps Layer
Docker
Multi-stage builds produce minimal production images. Development uses Docker Compose for local database, cache, and supporting services.
GitHub Actions
CI/CD pipeline that runs on every push: lint → typecheck → test → build → deploy. Matrix testing across Node versions, caching for fast builds, environment-specific deployment.
OpenTelemetry
Vendor-neutral observability. Traces, metrics, and logs flow from both frontend and backend through the OpenTelemetry protocol to your choice of backend (Grafana, Jaeger, Datadog, etc.).
How These Technologies Compose
Here is how data flows through the stack for a typical operation — creating a new project:
User clicks "Create Project"
│
▼
┌─── React Component ───┐
│ TanStack Form │ ← Form state, validation UI
│ + Effect Schema │ ← Runtime validation
│ → useMutation() │ ← TanStack Query mutation
└────────┬───────────────┘
│
▼
┌─── API Layer ──────────┐
│ Server Function / API │ ← Type-safe contract
│ + Effect Schema decode │ ← Request validation
└────────┬───────────────┘
│
▼
┌─── Effect Service ─────┐
│ ProjectService.create │ ← Business logic
│ → AuthService check │ ← Dependency injection
│ → Validation rules │ ← Domain validation
│ → AuditService.log │ ← Cross-cutting concerns
└────────┬───────────────┘
│
▼
┌─── Prisma Layer ───────┐
│ prisma.project.create │ ← Type-safe query
│ → Database write │ ← Migration-managed schema
└────────┬───────────────┘
│
▼
┌─── Response Flow ──────┐
│ Effect → Result type │ ← Typed success/error
│ → TanStack Query cache │ ← Automatic invalidation
│ → React re-render │ ← UI update
└────────────────────────┘
At every boundary, types flow through. The TypeScript compiler verifies that:
- The form fields match the schema
- The API request matches the server's expected input
- The service dependencies are all provided
- The Prisma query matches the database schema
- The error types are handled
- The response type matches what the component expects
This is what end-to-end type safety looks like in practice.
Technologies We Deliberately Excluded
| Technology | Why Not |
|---|---|
| Next.js | Platform lock-in (Vercel), complex caching semantics, opinionated file conventions that limit architectural flexibility |
| Redux | Excessive boilerplate for most apps. TanStack Query handles server state; Zustand handles client state more simply |
| Zod | Effect Schema provides the same validation with better integration into the Effect ecosystem |
| tRPC | Great library, but TanStack Start's server functions provide similar type safety with less infrastructure |
| GraphQL | Adds complexity (schema, resolvers, codegen) that most apps don't need. REST + type-safe schemas is simpler |
| Styled Components / CSS Modules | Tailwind CSS v4 is faster, more consistent, and has better tooling support |
| Jest | Vitest is faster, shares Vite config, and has a compatible API. No reason to use Jest in a Vite project |
Summary
Our stack is built on three layers:
- Type-safe data flow: TypeScript + Prisma + Effect Schema ensure types flow from database to UI
- Composable architecture: Effect + TanStack provide composable primitives rather than opinionated frameworks
- Production tooling: Vitest + Playwright + Docker + GitHub Actions + OpenTelemetry cover the full lifecycle
Every library was chosen to work with every other library. There are no awkward integration points or competing abstractions. This is the advantage of choosing your stack deliberately rather than accumulating dependencies reactively.