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.tsfor API calls - Create
config/app.tsfor environment variables - Add
lib/utils/cn.tsfor 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.