Back to blog

How I structure a Next.js project after 3 years

Components everywhere, utils mixed with hooks, routes importing from random folders. After dozens of projects, here's the structure that actually scales.

February 13, 202612 min read
Next.jsArchitectureTypeScriptProject Structure
How I structure a Next.js project after 3 years
The Folder Chaos - From messy app/ to clean separation of concerns
The Folder Chaos - From messy app/ to clean separation of concerns

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

WhatWhereExample
Pages & routesapp/app/dashboard/page.tsx
Generic UIcomponents/ui/button.tsx, input.tsx
Feature componentscomponents/features/login-form.tsx
Custom hookshooks/use-user.ts
API calls & logicservices/user.service.ts
DB, auth, utilslib/db.ts, auth.ts
TypeScript typestypes/index.ts
App configconfig/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.