Skip to main content
BlogWeb Dev

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

Beyond the tutorials: a battle-tested folder structure and tsconfig setup for TypeScript projects that scale without becoming a maze.

Matthews Wong

Software Engineer

June 202510 min read
TypeScript Project Structure for Real-World Apps: What Actually Works

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 Folder Structure That Survives Rapid Feature Addition

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 /components Split: UI vs Features

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.

Services Layer: Keeping Data Fetching Out of Components

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, enums

Add 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.

tsconfig.json: The Settings That Save You From Yourself

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.

Typing API Responses Defensively

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"
  )
}

Enforcing Structure With ESLint and Architectural Boundaries

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.

Monorepo Considerations: When to Split Packages

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 Litmus Test: Can a New Developer Find Things?

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.

Sources & Further Reading

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why should I separate components/ui from components/features instead of keeping all components in one folder?

Separating components/ui (the design system: Button, Input, Modal, Card, Badge) from components/features (domain-specific composites like UserCard, InvoiceTable, ApprovalBadge) enforces a clear contract: UI components are dumb — they accept props, render markup, and have no data fetching or business logic. Feature components can compose UI primitives and call hooks. This boundary makes it immediately obvious where to add new code and prevents business logic from leaking into your design system.

Which tsconfig options matter most beyond enabling strict mode?

Beyond strict: true, the three options with the most impact on real codebases are noUncheckedIndexedAccess, exactOptionalPropertyTypes, and noImplicitReturns. noUncheckedIndexedAccess is the most impactful — it forces TypeScript to treat arr[0] as T | undefined rather than T, requiring you to handle the case where an array index doesn't exist. Together these catch a significant class of bugs that strict alone misses.

How should I safely type API responses to avoid hidden 'any' types?

response.json() returns any, so without explicit narrowing, your type safety is only theoretical. The recommended two-layer approach is to write a Zod schema for runtime validation of the response shape, then derive the TypeScript interface from it using z.infer. This gives you both compile-time type safety and runtime validation that the actual API response matches what you expect.

When do barrel files (index.ts re-exports) cause problems and how should I handle them?

Barrel files are useful for feature directories — they keep imports clean across feature boundaries. However, adding barrel files to components/ui (the design system) creates circular dependency risks and slows down TypeScript's language server on large projects. The guidance is to add an index.ts to every feature directory but keep UI component imports explicit and direct. If your IDE feels slow or tsc --watch is sluggish, removing barrel files from hot paths is a good first diagnostic step.

When does a project outgrow a single Next.js repository and need a Turborepo monorepo?

The rule of thumb is: if two different apps need to share types or components, extract them to a separate package in a Turborepo monorepo. If it's just one app with many features, good folder organisation inside a single Next.js project is sufficient. Splitting too early adds overhead without benefit, so the trigger should be a concrete sharing need — a separate backend, a mobile app, or a shared component library.