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 withpnpm --version) - Git (check with
git --version)
Why pnpm​
We use pnpm as our package manager for several reasons:
- 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. - 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.
- Workspace support: Native monorepo support through
pnpm-workspace.yaml. No additional tooling needed. - Speed: Faster installs than npm and yarn in most benchmarks.
- Lockfile quality:
pnpm-lock.yamlis 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.
Monorepo Structure (Optional but Recommended)​
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.