50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 18, 2026·8 min read

File Structure That Actually Scales in Large Next.js Apps

The folder structure that works for a 3-page app will betray you at 50 routes. Here's what we've learned building production Next.js apps that don't turn into spaghetti.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

File Structure That Actually Scales in Large Next.js Apps

We've all been there. You start a Next.js project with the best intentions — clean folders, sensible names, a structure that feels logical at 9am on a Monday. Six months later you're staring at a `components` folder with 94 files and no idea where anything lives. We've killed three projects this way. Here's the structure we've landed on after enough pain to actually learn something.

The problem with 'just use components/'

The default Next.js setup doesn't tell you anything about how to organize your actual application code. It gives you `pages/` or `app/` and the rest is your problem. Most tutorials show you a flat `components/` directory and call it a day. That works great for a landing page. For a real SaaS with auth, billing, dashboards, and multiple user roles — it collapses fast.

The symptoms are easy to recognize: you have a `Button.tsx` that does three different things depending on where it's used, a `utils.ts` that's 800 lines long, and every new feature gets dumped into `components/` because nobody wants to make a decision. Eventually the codebase becomes a place nobody wants to touch.

The structure we actually use

Here's the top-level layout we've settled on for production apps. Everything lives inside `src/` — this isn't religious, but it keeps your app code separate from config files at the root level.

src/
├── app/                    # Next.js App Router routes
│   ├── (auth)/             # Route groups
│   │   ├── login/
│   │   └── signup/
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── settings/
│   └── api/
│       ├── auth/
│       └── webhooks/
├── components/             # Shared UI components only
│   ├── ui/                 # Primitives: Button, Input, Modal
│   └── shared/             # Composed shared components
├── features/               # Feature modules (the real meat)
│   ├── auth/
│   ├── billing/
│   ├── dashboard/
│   └── settings/
├── lib/                    # Third-party integrations
│   ├── stripe.ts
│   ├── resend.ts
│   └── db.ts
├── hooks/                  # Global reusable hooks
├── types/                  # Global TypeScript types
└── utils/                  # Pure utility functions

The key insight is the `features/` directory. This is where the actual application logic lives, and it's organized by domain rather than by technical layer. More on that in a second.

Feature modules: organize by domain, not by type

The classic mistake is organizing by technical type: all components in one place, all hooks in another, all server actions somewhere else. This feels logical until you're working on the billing feature and jumping between five different directories to trace a single user flow. Instead, keep everything related to a feature together.

features/
└── billing/
    ├── components/         # Components only used in billing
    │   ├── PricingTable.tsx
    │   ├── InvoiceList.tsx
    │   └── SubscriptionCard.tsx
    ├── hooks/              # Billing-specific hooks
    │   ├── useSubscription.ts
    │   └── useInvoices.ts
    ├── actions.ts          # Server actions for billing
    ├── queries.ts          # DB queries / data fetching
    ├── types.ts            # Billing-specific types
    └── utils.ts            # Billing helpers (format price, etc.)

When you need to touch the billing feature, you open `features/billing/` and almost everything you need is right there. You're not hunting through a global `hooks/` folder trying to remember if `useSubscription` lives there or somewhere else. New team member? They can open a feature folder and understand the full scope of that domain in minutes.

The rule we follow: if a component, hook, or utility is only used within one feature, it lives in that feature's folder. If it's used in two or more features, it moves to the shared level.

The components/ directory — keeping it lean

The global `components/` directory should be for genuinely reusable UI — the stuff that knows nothing about your application domain. We split it into two layers:

  • `components/ui/` — primitives like Button, Input, Card, Badge, Modal, Tooltip. These are basically your design system components. They take props, they render stuff, they don't call APIs.
  • `components/shared/` — composed components that combine UI primitives but are still domain-agnostic. Things like `PageHeader`, `DataTable`, `EmptyState`, `ConfirmDialog`. They might accept callbacks but don't do data fetching themselves.

If you're using shadcn/ui, the generated components land in `components/ui/` by default — which actually fits this model perfectly. We've started all our templates this way and it holds up well even as the UI component count grows past 40-50 components.

Route organization with App Router

The App Router gives you route groups with `(parentheses)` which are genuinely useful for sharing layouts without affecting the URL. Use them to separate concerns, not just to avoid repeating layout code.

// app/(dashboard)/layout.tsx
// This layout wraps all dashboard routes but "(dashboard)" doesn't appear in the URL

import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { Sidebar } from '@/features/dashboard/components/Sidebar'
import { TopNav } from '@/features/dashboard/components/TopNav'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await getSession()

  if (!session) {
    redirect('/login')
  }

  return (
    <div className="flex h-screen">
      <Sidebar user={session.user} />
      <div className="flex flex-col flex-1 overflow-hidden">
        <TopNav user={session.user} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  )
}

One thing we've learned: don't put business logic in your route files. The `page.tsx` files should be thin — they fetch data, pass it to feature components, and handle the top-level layout. The actual UI and logic live in the feature modules.

// app/(dashboard)/billing/page.tsx
// Thin route file — just orchestration

import { getSubscription, getInvoices } from '@/features/billing/queries'
import { BillingOverview } from '@/features/billing/components/BillingOverview'
import { InvoiceList } from '@/features/billing/components/InvoiceList'

export default async function BillingPage() {
  const [subscription, invoices] = await Promise.all([
    getSubscription(),
    getInvoices(),
  ])

  return (
    <div className="space-y-8">
      <BillingOverview subscription={subscription} />
      <InvoiceList invoices={invoices} />
    </div>
  )
}

This pattern means your route files are almost always under 30 lines. When something breaks in billing, you go to `features/billing/` — not into the app router directory.

The lib/ directory — your third-party contract

`lib/` is where you initialize and export third-party clients. One file per service. The entire rest of the app imports from `@/lib/stripe` or `@/lib/db` — never directly from `stripe` or `drizzle-orm`. This is the abstraction layer that will save you when you swap Resend for Postmark at 2am because your emails started landing in spam.

// lib/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from '@/db/schema'

const connectionString = process.env.DATABASE_URL!

// Prevent multiple instances in development (Next.js hot reload problem)
const globalForDb = globalThis as unknown as {
  connection: postgres.Sql | undefined
}

const connection = globalForDb.connection ?? postgres(connectionString)

if (process.env.NODE_ENV !== 'production') {
  globalForDb.connection = connection
}

export const db = drizzle(connection, { schema })

Keep the initialization logic in `lib/`, keep the query logic in `features/[domain]/queries.ts`. Don't mix them. When you inevitably need to add connection pooling or switch databases, you change one file.

Barrel files: use them carefully

Barrel files (`index.ts` that re-exports from a directory) are useful for feature modules but can cause real problems if you go overboard. The issue is that bundlers sometimes struggle with tree-shaking through deep barrel hierarchies, and you can accidentally pull in server-only code on the client.

Our rule: barrel files are fine at the feature level to expose a clean public API for that feature. Avoid them for the `components/ui/` directory if you're using a tool like shadcn — import directly from the component file instead. One badly structured barrel file once added 40kb to our client bundle because a server utility got accidentally re-exported through it. We found this at 2am the night before launch. Good times.

// features/billing/index.ts — clean public API for the billing feature
// Only export what other parts of the app need to use

export { BillingOverview } from './components/BillingOverview'
export { useSubscription } from './hooks/useSubscription'
export type { Subscription, Invoice } from './types'

// DO NOT export internal helpers or server-only queries here
// Those get imported directly within the feature

Path aliases — stop writing ../../../../

This one's quick but worth saying. Set up path aliases in your `tsconfig.json` and use them everywhere. Relative paths with multiple `../` are fragile — moving a file breaks imports silently and makes grep-based refactoring a nightmare.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/features/*": ["./src/features/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/hooks/*": ["./src/hooks/*"],
      "@/types/*": ["./src/types/*"],
      "@/utils/*": ["./src/utils/*"]
    }
  }
}

Next.js supports `@/*` out of the box if you opt in during `create-next-app`. Use it. Your future self debugging a production issue will thank you for being able to read import paths that actually make sense.

When the structure breaks down — and what to do

No structure survives contact with a real product perfectly. A few situations where this setup gets awkward and how we handle them:

  • **Cross-feature dependencies** — If `features/dashboard` needs something from `features/billing`, import it through the billing barrel file, not from deep inside billing's internals. If you find two features constantly reaching into each other, that's a sign they should merge into one or share a third module.
  • **Shared state** — Global state (Zustand stores, React Context) that spans multiple features lives in a top-level `store/` or `context/` directory, not inside a feature. Feature-local state stays in the feature.
  • **Growing `utils/`** — When `utils/` starts growing, split by domain: `utils/formatting.ts`, `utils/validation.ts`, `utils/dates.ts`. A single 600-line utils file is just a different kind of mess.
  • **API routes** — For complex API routes, mirror the feature structure: `app/api/billing/` contains routes that call into `features/billing/actions.ts`. Don't put business logic directly in route handlers.

The templates we ship at peal.dev are all built with this structure baked in from the start. It's much easier to start with good bones and relax them where needed than to untangle a flat structure that's grown out of control. You can see exactly how we wire the features together when you look through a template — the structure tells you the story of the app.

Good file structure isn't about following rules — it's about making sure the next person (or future you at 11pm) can find what they need in under 30 seconds. If you have to think for more than that, reorganize.

One final thing: enforce it with your team or your future self. Add a brief `STRUCTURE.md` at the root explaining the conventions. Not because people can't figure it out, but because it signals that this was a deliberate decision and not just how files happened to land. That single file has saved us from PRs that dump ten components into the wrong folder more times than we can count.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders