
My first Next.js project was a mess.
Components everywhere. Utils mixed with hooks. API routes that imported from random folders. Every time I needed to find something, I spent 5 minutes searching.
Three years later, I've worked on dozens of Next.js projects. Some solo, some with teams of 10+. I've tried every structure I found on the internet. Most failed when the project grew.
Here's what actually works.
The structure I use today
src/
├── app/ # App Router (routes only)
│ ├── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ ├── (main)/
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ └── loading.tsx
│ │ └── settings/
│ │ └── page.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── route.ts
│ ├── layout.tsx
│ └── page.tsx
│
├── components/
│ ├── ui/ # Design system (buttons, inputs, cards)
│ │ ├── button.tsx
│ │ ├── input.tsx
│ │ └── card.tsx
│ └── features/ # Feature-specific components
│ ├── auth/
│ │ ├── login-form.tsx
│ │ └── register-form.tsx
│ └── dashboard/
│ ├── stats-card.tsx
│ └── recent-activity.tsx
│
├── lib/ # Core utilities
│ ├── db.ts # Database client
│ ├── auth.ts # Auth configuration
│ └── utils.ts # Generic helpers
│
├── hooks/ # Custom React hooks
│ ├── use-user.ts
│ └── use-media-query.ts
│
├── services/ # Business logic & API calls
│ ├── user.service.ts
│ └── payment.service.ts
│
├── types/ # TypeScript types
│ └── index.ts
│
└── config/ # App configuration
└── site.ts
Let me explain why each decision matters.
Lesson 1: keep app/ for routing only
The app/ folder is for routing. That's it.
I used to put components, utils, and all kinds of stuff inside app/. Big mistake. The folder became impossible to navigate.
# ❌ What I used to do
app/
├── dashboard/
│ ├── page.tsx
│ ├── DashboardHeader.tsx
│ ├── DashboardSidebar.tsx
│ ├── useDashboardData.ts
│ └── dashboard.utils.ts
# ✅ What I do now
app/
├── dashboard/
│ ├── page.tsx
│ └── loading.tsx
components/features/dashboard/
├── dashboard-header.tsx
├── dashboard-sidebar.tsx
└── stats-card.tsx
hooks/
└── use-dashboard-data.ts
The page file imports what it needs. The routing structure stays clean.
Lesson 2: route groups change everything
Route groups (folders with parentheses) let you organize routes without affecting URLs.
app/
├── (auth)/ # URL: /login, /register
│ ├── login/
│ └── register/
├── (main)/ # URL: /dashboard, /settings
│ ├── dashboard/
│ └── settings/
└── (marketing)/ # URL: /, /pricing, /about
├── page.tsx
├── pricing/
└── about/
Each group can have its own layout:
// app/(auth)/layout.tsx
export default function AuthLayout({ children }) {
return (
<div className="min-h-screen flex items-center justify-center">
{children}
</div>
);
}
// app/(main)/layout.tsx
export default function MainLayout({ children }) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">{children}</main>
</div>
);
}
No more conditional layouts in a single file. Each section has its own structure.
Lesson 3: separate UI components from feature components
This took me years to figure out.
UI components are generic, reusable building blocks. Buttons, inputs, cards, modals. They don't know about your business logic.
Feature components are specific to your app. They know about users, payments, dashboards. They use UI components.
components/
├── ui/ # Generic, reusable
│ ├── button.tsx # <Button variant="primary">
│ ├── input.tsx # <Input label="Email">
│ ├── card.tsx # <Card title="Stats">
│ └── modal.tsx # <Modal open={true}>
│
└── features/ # App-specific
├── auth/
│ ├── login-form.tsx # Uses <Button>, <Input>
│ └── user-avatar.tsx
└── billing/
├── pricing-card.tsx
└── payment-form.tsx
When I need a button, I go to ui/. When I need the login form, I go to features/auth/.
No more searching through 50 components.
Lesson 4: services handle business logic
I used to put API calls everywhere. In components. In hooks. In utils.
Now everything goes through services:
// services/user.service.ts
export const userService = {
async getById(id: string) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
},
async update(id: string, data: UpdateUserData) {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update user');
return response.json();
},
async delete(id: string) {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete user');
},
};
Components use services, they don't know about fetch:
// components/features/settings/profile-form.tsx
import { userService } from '@/services/user.service';
function ProfileForm({ userId }) {
async function handleSubmit(data) {
await userService.update(userId, data);
}
// ...
}
Benefits:
- API logic in one place
- Easy to test (mock the service)
- Easy to change (swap fetch for axios, add caching)
Lesson 5: the lib/ folder is for infrastructure
lib/ contains the stuff your app needs to run. Database connections. Auth setup. External service clients.
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
// lib/auth.ts
import NextAuth from 'next-auth';
import { authConfig } from '@/config/auth';
export const { auth, signIn, signOut } = NextAuth(authConfig);
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
lib/ is low-level. Services and components import from lib/, never the other way around.
Lesson 6: colocate when it makes sense
Sometimes a component is only used in one place. Don't create a folder for it.
app/
├── dashboard/
│ ├── page.tsx
│ └── _components/ # Only used in dashboard
│ └── welcome-banner.tsx
The underscore prefix (_components) tells Next.js to ignore it for routing. It stays close to where it's used.
I use this for:
- Components used by a single page
- Page-specific hooks
- Local types
If I need it elsewhere later, I move it to components/features/.
Lesson 7: absolute imports save sanity
No more ../../../../components/ui/button.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
Now:
import { Button } from '@/components/ui/button';
import { userService } from '@/services/user.service';
import { useUser } from '@/hooks/use-user';
Clean. Clear. No mental math to figure out the path.
What I stopped doing
Organizing by type (the "classic" structure)
# ❌ Don't do this
components/
├── Header.tsx
├── Footer.tsx
├── Button.tsx
├── LoginForm.tsx
├── DashboardStats.tsx
├── UserAvatar.tsx
└── ... 50 more files
This doesn't scale. At 50 components, you're lost.
Putting everything in utils/
# ❌ Don't do this
utils/
├── api.ts
├── auth.ts
├── db.ts
├── format.ts
├── validation.ts
└── ... everything else
utils/ becomes a junk drawer. Split it into lib/, services/, and helpers/.
Over-engineering from day one
I've seen projects with 20 folders and 5 files. Don't do that.
Start simple. Add structure when you feel pain. If you're searching for files, you need better organization. If you're not, you're fine.
The decision tree
The cheat sheet
| What | Where | Example |
|---|---|---|
| Pages & routes | app/ | app/dashboard/page.tsx |
| Generic UI | components/ui/ | button.tsx, input.tsx |
| Feature components | components/features/ | login-form.tsx |
| Custom hooks | hooks/ | use-user.ts |
| API calls & logic | services/ | user.service.ts |
| DB, auth, utils | lib/ | db.ts, auth.ts |
| TypeScript types | types/ | index.ts |
| App config | config/ | site.ts |
The lesson
Structure isn't about following rules. It's about finding things fast.
When I open a project after 6 months, I should know where everything is. When a new developer joins, they shouldn't need a tour.
The best structure is the one that makes you think less.
This is part 3 of my "What I learned the hard way" series. Next up: what 3 years of code reviews taught me.
Continue to part 4: What 3 years of code reviews taught me →
Got questions? Hit me up on LinkedIn or check out more on my blog.