TypeScript Project Structure for Real-World Apps: What Actually Works

Photo by Unsplash

Photo by Unsplash
Every TypeScript tutorial shows you the same starter structure: src/components, src/pages, maybe src/utils. It works fine for a todo app. Then you start a real project with authentication, billing, external APIs, multiple user roles, and a design system — and that flat structure starts to collapse within weeks. I've restructured codebases from scratch more than once, and the folder layout I keep coming back to isn't the most clever or the most theoretically pure. It's the one that keeps pull reviews fast and new contributors unblocked.
The core principle: group by feature boundary, not by file type. Putting all hooks in /hooks, all types in /types, and all components in /components sounds organized — until you're working on a feature and need to jump between six directories for related code. Instead, keep domain-specific code co-located inside feature folders, and use shared top-level directories only for genuinely cross-cutting concerns like the design system, utilities, and global types.
The single most impactful organizational decision is separating components/ui (the design system — Button, Input, Modal, Card, Badge) from components/features (domain-specific composites — UserCard, InvoiceTable, ApprovalBadge). UI components are dumb: they accept props, render markup, have no data fetching, no business logic. Feature components can be smarter — they might call a hook that fetches data, or compose multiple UI primitives.
A services/ directory for all external API calls is a pattern I adopted from NestJS and brought into my Next.js work. Each service file is responsible for one external boundary: auth.ts for authentication, billing.ts for payment APIs, erp.ts for the internal ERP API. Services are plain async functions — no React, no hooks, no JSX. This makes them trivially testable in isolation.
src/
├── app/ # Next.js App Router pages
│ ├── [locale]/
│ │ ├── (pages)/
│ │ │ ├── dashboard/
│ │ │ └── settings/
│ │ └── layout.tsx
│ └── api/ # Route handlers
│ └── v1/
├── components/ # UI components (dumb, presentational)
│ ├── ui/ # Design system atoms (Button, Input…)
│ └── features/ # Domain-specific composites
├── lib/ # Pure utilities (no React deps)
│ ├── utils.ts
│ └── validators.ts
├── services/ # External API calls, data fetching
│ ├── auth.ts
│ └── billing.ts
├── hooks/ # Custom React hooks
├── stores/ # Zustand / Jotai state
├── types/ # Shared TypeScript types & interfaces
│ ├── api.ts
│ └── domain.ts
└── constants/ # App-wide constants, enumsAdd an index.ts barrel file to every feature directory, but NOT to components/ui. Barrel files for the design system create circular dependency risks and slow down TypeScript's language server on large projects. Let the UI component imports stay explicit.
TypeScript's default settings are too permissive for production code. Enabling strict: true is table stakes, but there are three additional options that catch a significant class of bugs in real codebases: noUncheckedIndexedAccess, exactOptionalPropertyTypes, and noImplicitReturns. noUncheckedIndexedAccess is the most impactful — it makes TypeScript treat arr[0] as T | undefined rather than T, forcing you to handle the case where an index doesn't exist.
The most dangerous any in a TypeScript codebase is the one hiding inside an API response. response.json() returns any, and if you don't narrow it, the type safety promise of your entire app is a lie. I use a two-layer approach: a Zod schema for runtime validation of the response shape, and a TypeScript interface derived from the schema via z.infer.
// tsconfig.json — production-grade config
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true, // never turn this off
"noUncheckedIndexedAccess": true, // catch arr[i] undefined
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"],
"@/services/*": ["./src/services/*"]
}
}
}
// types/domain.ts — co-locate domain types
export interface User {
readonly id: string
email: string
role: UserRole
createdAt: Date
}
export type UserRole = "admin" | "member" | "viewer"
// Never use 'any' — use 'unknown' and narrow:
function parseApiResponse(raw: unknown): User {
if (!isUser(raw)) throw new Error("Invalid user shape")
return raw
}
function isUser(val: unknown): val is User {
return (
typeof val === "object" && val !== null &&
"id" in val && typeof (val as User).id === "string"
)
}eslint-plugin-import with no-restricted-imports rules can enforce architectural boundaries automatically. Prevent services/ from importing from components/, prevent lib/ from importing React — these rules catch layering violations at lint time rather than in code review.
Over-indexing on barrel files (index.ts re-exports everywhere) is one of the most common causes of TypeScript language server slowdowns and circular dependency errors in large projects. If your IDE feels slow or tsc --watch is sluggish, remove barrel files from hot paths and use direct imports.
For projects that grow to include a separate backend, a mobile app, or a shared component library, a Turborepo monorepo with distinct packages becomes worthwhile. The rule of thumb I use: if two different apps need to share types or components, extract them to a package. If it's just one app with a lot of features, stay in a single Next.js project with good folder organization.
The final measure of any folder structure is human: can a developer who has never seen the codebase find the right file in under 60 seconds using only grep and directory browsing? If the answer is no, the structure is too clever. Keep feature code together, keep the design system separate, keep utilities pure, and write a one-paragraph ARCHITECTURE.md describing the conventions.