Command Palette

Search for a command to run...

0
Blog
PreviousNext

How I Structure a Next.js Project So It Scales Without Becoming a Mess

A battle-tested approach to organizing Next.js projects that keeps your codebase maintainable as it grows from MVP to production scale.

After working on dozens of Next.js projects—from small prototypes to applications serving millions of users—I've learned that starting with the right structure saves countless hours of refactoring later. Here's the approach I use that scales beautifully.

The Problem with Default Structures

The default Next.js structure is great for getting started, but it falls apart quickly when you're dealing with:

  • Multiple feature domains (auth, payments, analytics)
  • Shared components used across different parts of the app
  • Complex state management
  • Multiple API integrations
  • Growing team size

You end up with a messy components folder with 50+ files, unclear import paths, and nobody knows where to put new code.

My Base Structure

Here's the structure I start every project with:

src/
├── app/                    # Next.js App Router
│   ├── (auth)/            # Route groups for auth pages
│   ├── (dashboard)/       # Route groups for dashboard
│   ├── api/               # API routes
│   └── layout.tsx
├── components/
│   ├── ui/                # Shadcn/base components (Button, Input, etc)
│   ├── layout/            # Layout components (Header, Footer, Sidebar)
│   ├── forms/             # Form components
│   └── shared/            # Shared across features
├── features/              # Feature-based modules
│   ├── auth/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── utils/
│   │   └── types.ts
│   ├── posts/
│   └── comments/
├── lib/
│   ├── api/               # API client utilities
│   ├── db/                # Database utilities
│   ├── utils/             # Pure utility functions
│   └── constants.ts
├── hooks/                 # Global custom hooks
├── types/                 # Global TypeScript types
├── config/                # App configuration
└── styles/

The Features Folder: Game Changer

The features/ folder is where the magic happens. Each feature is self-contained:

features/
└── posts/
    ├── components/
    │   ├── PostCard.tsx
    │   ├── PostList.tsx
    │   └── CreatePostForm.tsx
    ├── hooks/
    │   ├── usePost.ts
    │   └── usePosts.ts
    ├── api/
    │   └── posts.ts          # API calls specific to posts
    ├── utils/
    │   └── validators.ts     # Post validation logic
    ├── types.ts              # Post-related types
    └── constants.ts          # Post-related constants

Why this works:

  • Colocation: Everything related to posts is in one place
  • Clear boundaries: Easy to see feature scope
  • Team scaling: Multiple developers can work on different features without conflicts
  • Lazy loading: Can easily code-split by feature
  • Testing: Easier to test features in isolation

Import Path Convention

I use these import aliases in tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/features/*": ["./src/features/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/hooks/*": ["./src/hooks/*"],
      "@/types/*": ["./src/types/*"],
      "@/config/*": ["./src/config/*"]
    }
  }
}

This gives you clean, predictable imports:

// Bad
import { Button } from "../../../components/ui/button";
 
// Good
import { Button } from "@/components/ui/button";
import { usePost } from "@/features/posts/hooks/usePost";

API Layer Organization

I keep API logic separate from components:

// lib/api/client.ts
export const apiClient = {
  get: async <T>(url: string) => {
    /* ... */
  },
  post: async <T>(url: string, data: any) => {
    /* ... */
  },
  // ...
};
 
// features/posts/api/posts.ts
import { apiClient } from "@/lib/api/client";
import type { Post, CreatePostDto } from "../types";
 
export const postsApi = {
  getAll: () => apiClient.get<Post[]>("/api/posts"),
  getById: (id: string) => apiClient.get<Post>(`/api/posts/${id}`),
  create: (data: CreatePostDto) => apiClient.post<Post>("/api/posts", data),
};

Benefits:

  • API logic is reusable and testable
  • Easy to mock for testing
  • Can swap implementations (REST to GraphQL)
  • Type-safe by default

Shared vs Feature Components

Rule of thumb: If a component is used in two or more features, it goes in components/shared/. Otherwise, keep it in the feature folder.

components/
├── ui/              # Base components (Button, Card, etc)
├── layout/          # Header, Footer, Sidebar
├── forms/           # Generic form components
└── shared/          # Used across multiple features
    ├── UserAvatar.tsx
    ├── LoadingSpinner.tsx
    └── EmptyState.tsx

features/
└── posts/
    └── components/   # Only used for posts
        └── PostCard.tsx

This prevents the shared components folder from becoming a dumping ground.

Configuration Management

Environment-specific config goes in config/:

// config/app.ts
export const appConfig = {
  name: "MyApp",
  url: process.env.NEXT_PUBLIC_APP_URL!,
  api: {
    baseUrl: process.env.NEXT_PUBLIC_API_URL!,
    timeout: 30000,
  },
} as const;
 
// config/features.ts
export const features = {
  enableComments: process.env.NEXT_PUBLIC_ENABLE_COMMENTS === "true",
  enableAnalytics: process.env.NEXT_PUBLIC_ENABLE_ANALYTICS === "true",
} as const;

Now you have a single source of truth for all configuration.

Type Organization

Global types go in types/, feature-specific types stay in features:

// types/index.ts
export type User = {
  id: string;
  email: string;
  name: string;
};
 
export type ApiResponse<T> = {
  data: T;
  error?: string;
};
 
// features/posts/types.ts
import type { User } from "@/types";
 
export type Post = {
  id: string;
  title: string;
  content: string;
  author: User;
  createdAt: string;
};

Server Actions Organization

With Next.js Server Actions, I keep them close to where they're used:

app/
└── (dashboard)/
    └── posts/
        ├── page.tsx
        └── actions.ts        # Server actions for this route

features/
└── posts/
    └── actions/
        ├── create-post.ts    # Reusable across multiple routes
        └── delete-post.ts

Route-specific actions stay in the app directory. Reusable actions go in the feature folder.

Database Layer

For database code, I use a similar pattern:

lib/
└── db/
    ├── client.ts          # Prisma/Drizzle client
    ├── schema/            # Database schema
    └── migrations/

features/
└── posts/
    └── db/
        ├── queries.ts     # Post-related queries
        └── mutations.ts   # Post-related mutations

This keeps database logic organized by domain.

Hooks Organization

Global hooks (used everywhere) go in hooks/, feature-specific hooks stay in features:

// hooks/useMediaQuery.ts
export function useMediaQuery(query: string) {
  // Generic hook used across app
}
 
// features/posts/hooks/usePost.ts
export function usePost(id: string) {
  // Post-specific hook
}

Utils Organization

Same principle applies to utilities:

// lib/utils/formatters.ts
export function formatDate(date: Date) {
  // Generic formatter
}
 
// features/posts/utils/validators.ts
export function validatePost(data: unknown) {
  // Post-specific validation
}

The Benefits I've Seen

After using this structure across multiple projects:

  • Faster onboarding: New developers find things easily
  • Better PRs: Changes are localized to specific features
  • Easier refactoring: Clear boundaries make it safer to change code
  • Natural code splitting: Features can be lazy-loaded independently
  • Clearer testing: Test features in isolation
  • Less merge conflicts: Team members work in different feature folders

When to Break the Rules

This structure isn't dogmatic. Break it when it makes sense:

  • Tiny projects: Skip the features folder if you have fewer than five features
  • Microservices: You might split features into separate repos
  • Monorepos: Features might become separate packages
  • Legacy migrations: Gradually adopt this structure

Example: Real File Structure

Here's what a real project looks like with this structure:

src/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   └── register/
│   ├── (dashboard)/
│   │   ├── posts/
│   │   ├── settings/
│   │   └── layout.tsx
│   ├── api/
│   │   └── posts/
│   └── layout.tsx
├── components/
│   ├── ui/
│   │   ├── button.tsx
│   │   └── card.tsx
│   ├── layout/
│   │   ├── header.tsx
│   │   └── sidebar.tsx
│   └── shared/
│       ├── user-avatar.tsx
│       └── loading-spinner.tsx
├── features/
│   ├── auth/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api/
│   │   └── types.ts
│   ├── posts/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api/
│   │   ├── utils/
│   │   └── types.ts
│   └── comments/
├── lib/
│   ├── api/
│   │   └── client.ts
│   ├── db/
│   │   └── client.ts
│   └── utils/
│       ├── cn.ts
│       └── formatters.ts
├── hooks/
│   ├── useMediaQuery.ts
│   └── useDebounce.ts
├── types/
│   └── index.ts
└── config/
    ├── app.ts
    └── features.ts

Quick Start Checklist

When starting a new Next.js project, I follow these steps:

  • Set up import aliases in tsconfig.json
  • Create the base folder structure
  • Add a features/ folder for the first feature
  • Set up lib/api/client.ts for API calls
  • Create config/app.ts for environment variables
  • Add lib/utils/cn.ts for className utilities
  • Document the structure in README.md

Conclusion

A good project structure is invisible when it's working. You shouldn't think about where to put files—it should be obvious. This structure has saved me from "big refactoring" moments that derail projects.

The key principles:

  • Colocation: Keep related code together
  • Clear boundaries: Features are self-contained
  • Predictable imports: Consistent path aliases
  • Progressive complexity: Start simple, add structure as you grow

Start with this structure from day one, and future you will be grateful.


Want to see this in action? Check out my open-source Next.js starter template that implements this exact structure.