Skip to main content

Chapter 3: Project Setup & Scaffolding

This chapter walks through creating the project from scratch. By the end, you will have a running development environment with all core dependencies installed and configured.

Prerequisites​

Ensure you have installed:

  • Node.js 20+ (LTS recommended — check with node --version)
  • pnpm 9+ (install with npm install -g pnpm — check with pnpm --version)
  • Git (check with git --version)

Why pnpm​

We use pnpm as our package manager for several reasons:

  1. Disk efficiency: pnpm uses a content-addressable store. If 10 projects use React, pnpm stores React once on disk and hard-links it into each project's node_modules.
  2. Strict dependency resolution: pnpm does not hoist packages to the root by default. If you did not declare a dependency, you cannot import it. This prevents "phantom dependencies" that break in production.
  3. Workspace support: Native monorepo support through pnpm-workspace.yaml. No additional tooling needed.
  4. Speed: Faster installs than npm and yarn in most benchmarks.
  5. Lockfile quality: pnpm-lock.yaml is deterministic and produces consistent installs across machines.

Creating the Project​

Step 1: Initialize with Vite​

pnpm create vite@latest taskforge -- --template react-ts
cd taskforge

This scaffolds a React + TypeScript project with Vite. The template gives us:

taskforge/
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── vite.config.ts
├── public/
│ └── vite.svg
└── src/
├── App.tsx
├── App.css
├── main.tsx
├── index.css
├── vite-env.d.ts
└── assets/
└── react.svg

Step 2: Install Core Dependencies​

# Core framework
pnpm add react react-dom
pnpm add -D @types/react @types/react-dom

# TanStack ecosystem
pnpm add @tanstack/react-router @tanstack/react-query @tanstack/react-table @tanstack/react-form
pnpm add -D @tanstack/router-plugin @tanstack/router-devtools @tanstack/react-query-devtools

# Effect ecosystem
pnpm add effect @effect/schema @effect/platform

# Prisma (we'll configure the schema later)
pnpm add @prisma/client
pnpm add -D prisma

# Styling
pnpm add tailwindcss @tailwindcss/vite
pnpm add -D @types/node

# UI Components (shadcn/ui — we'll add specific components later)
pnpm add class-variance-authority clsx tailwind-merge lucide-react
pnpm add @radix-ui/react-slot

# Client state
pnpm add zustand

# Utilities
pnpm add date-fns

Step 3: Install Development Dependencies​

# Testing
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom happy-dom

# E2E Testing
pnpm add -D @playwright/test

# Code Quality
pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-react-refresh
pnpm add -D prettier eslint-plugin-prettier eslint-config-prettier
pnpm add -D eslint-plugin-jsx-a11y

# Git hooks
pnpm add -D husky lint-staged

Step 4: Initialize Additional Tools​

# Initialize Prisma
pnpm prisma init --datasource-provider postgresql

# Initialize Playwright
pnpm playwright install

# Initialize Husky
pnpm husky init

Vite Configuration​

Replace the default vite.config.ts with our production-ready configuration:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import path from "node:path";

export default defineConfig({
plugins: [
// TanStack Router plugin — generates route types from file structure
// MUST be listed before react() plugin
TanStackRouterVite({
routesDirectory: "./src/routes",
generatedRouteTree: "./src/route-tree.gen.ts",
}),

// React plugin — handles JSX transform and Fast Refresh
react(),

// Tailwind CSS v4 — processes utility classes
tailwindcss(),
],

resolve: {
alias: {
// Path alias: import from "@/components/Button" instead of "../../components/Button"
"@": path.resolve(__dirname, "./src"),
},
},

server: {
// Lock the dev server port for consistent team environments
port: 5173,
strictPort: true,

// Proxy API requests to backend during development
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},

build: {
// Generate source maps for production debugging
sourcemap: true,

// Target modern browsers
target: "es2022",

rollupOptions: {
output: {
// Split vendor chunks for better caching
manualChunks: {
"react-vendor": ["react", "react-dom"],
"tanstack-vendor": [
"@tanstack/react-router",
"@tanstack/react-query",
],
"effect-vendor": ["effect"],
},
},
},
},

// Vitest configuration (shared with vite config)
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/route-tree.gen.ts",
],
},
},
});

Key Configuration Decisions​

Path aliases (@/): Prevents deeply nested relative imports like ../../../components/Button. Every import starts from @/ which maps to src/.

Locked port: The entire team uses port 5173. This simplifies documentation, API proxy configuration, and CORS settings.

API proxy: During development, /api/* requests are forwarded to the backend server on port 3000. This avoids CORS issues without configuring CORS headers in development.

Source maps in production: Enables debugging production issues with original source code. Source maps can be uploaded to error tracking services (Sentry, etc.) and stripped from public serving if desired.

Manual chunks: Vendor code changes less frequently than application code. Splitting it into separate chunks means users' browsers can cache vendor bundles across deployments.

Package.json Scripts​

// package.json
{
"name": "taskforge",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
// Development
"dev": "vite",
"dev:api": "tsx watch src/server/index.ts",

// Build
"build": "tsc -b && vite build",
"preview": "vite preview",

// Code Quality
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",

// Testing
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",

// Database
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"db:generate": "prisma generate",

// Pre-commit (run by Husky)
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css}": ["prettier --write"]
}
}

Test Setup File​

// src/test/setup.ts
import "@testing-library/jest-dom/vitest";

This single import adds all the custom matchers from @testing-library/jest-dom (toBeInTheDocument(), toBeDisabled(), toHaveTextContent(), etc.) to Vitest's expect.

Environment Variables​

Create the initial environment files:

# .env — Base configuration (committed to git)
VITE_APP_NAME=TaskForge
VITE_API_BASE_URL=/api

# .env.local — Local overrides (git-ignored)
DATABASE_URL="postgresql://user:password@localhost:5432/taskforge?schema=public"
SESSION_SECRET="change-me-in-production"
# .env.development — Development-specific
VITE_ENABLE_DEVTOOLS=true
VITE_LOG_LEVEL=debug

# .env.production — Production-specific
VITE_ENABLE_DEVTOOLS=false
VITE_LOG_LEVEL=error

Critical rule: Only variables prefixed with VITE_ are exposed to client-side code. Never put secrets in VITE_ variables — they will be embedded in your JavaScript bundle.

→ See Chapter 38: Environment Management for a complete treatment.

.gitignore​

# Dependencies
node_modules/
.pnpm-store/

# Build output
dist/
dist-ssr/

# Environment (local overrides only — base .env is committed)
.env.local
.env.*.local

# IDE
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/

# OS
.DS_Store
Thumbs.db

# Testing
coverage/
playwright-report/
test-results/

# Prisma
prisma/migrations/**/migration_lock.toml

# Generated
src/route-tree.gen.ts

# Logs
*.log

EditorConfig​

# .editorconfig
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

VS Code Settings (Team-Shared)​

// .vscode/settings.json
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": {
"*.css": "tailwindcss"
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}

// .vscode/extensions.json
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"prisma.prisma",
"ms-playwright.playwright"
]
}

Verify the Setup​

Run through this checklist to confirm everything is working:

# 1. Dev server starts
pnpm dev
# → Should open http://localhost:5173 with the Vite + React template

# 2. TypeScript compiles
pnpm typecheck
# → Should complete with no errors

# 3. Linting passes
pnpm lint
# → Should complete with no errors (or only expected warnings)

# 4. Tests run
pnpm test:run
# → Should find and run any test files

# 5. Build succeeds
pnpm build
# → Should create dist/ directory with optimized assets

# 6. Prisma generates client
pnpm db:generate
# → Should generate Prisma Client types

Initial File Cleanup​

Remove the Vite template files we will not use:

rm src/App.css src/App.tsx src/index.css
rm -rf src/assets
rm public/vite.svg

We will create our own entry point and app structure in the next chapters.

For larger applications, a monorepo keeps shared code, the frontend, and backend in a single repository. Here is the recommended structure:

# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
taskforge/
├── pnpm-workspace.yaml
├── package.json # Root — workspace scripts, shared devDeps
├── turbo.json # Turborepo task orchestration (optional)
├── apps/
│ ├── web/ # React frontend (this is what we've set up)
│ │ ├── package.json
│ │ ├── vite.config.ts
│ │ └── src/
│ └── api/ # Backend API server
│ ├── package.json
│ └── src/
├── packages/
│ ├── shared/ # Shared types, schemas, utilities
│ │ ├── package.json
│ │ └── src/
│ ├── db/ # Prisma schema and generated client
│ │ ├── package.json
│ │ ├── prisma/
│ │ └── src/
│ ├── ui/ # Shared UI components (design system)
│ │ ├── package.json
│ │ └── src/
│ └── config/ # Shared configs (TypeScript, ESLint, etc.)
│ ├── typescript/
│ └── eslint/

Shared Package Configuration​

// packages/shared/package.json
{
"name": "@taskforge/shared",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
}
}

Note the exports field points directly to TypeScript source (./src/index.ts). For internal packages, there is no need to compile to JavaScript — the consuming application's Vite build handles transpilation. This is the "direct-export" strategy for local packages.

Database Package​

// packages/db/package.json
{
"name": "@taskforge/db",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "latest"
},
"devDependencies": {
"prisma": "latest"
}
}
// packages/db/src/index.ts
export { PrismaClient } from "@prisma/client";
export type * from "@prisma/client";

// Singleton instance for the application
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

Summary​

In this chapter, we:

  • ✅ Initialized a Vite + React + TypeScript project
  • ✅ Installed all core dependencies (TanStack, Effect, Prisma, Tailwind)
  • ✅ Configured Vite with path aliases, API proxy, and vendor chunk splitting
  • ✅ Set up development tooling (ESLint, Prettier, Husky, lint-staged)
  • ✅ Created environment variable files with the VITE_ prefix convention
  • ✅ Configured VS Code for team consistency
  • ✅ Outlined the monorepo structure for larger applications

The project compiles, the dev server runs, and all tooling is in place. Next, we configure TypeScript for maximum type safety.