nextjsarchitectureapp routertypescriptmonorepo

Your Next.js Repo is a Mess. Here's How to Fix It.

Abhishek Kumbhani
Abhishek KumbhaniSDE-1
Apr 1, 2026·18 min read
Your Next.js Repo is a Mess. Here's How to Fix It.

You cloned a Next.js starter three months ago, shipped fast, and now nobody on the team can agree on where to put a new hook. Authentication logic lives in three different places. The components/ folder has 200 files. A junior dev asked where to put the Stripe webhook handler and got three different answers.

This happens because Next.js deliberately stays unopinionated about internal structure. The docs tell you about layout.tsx and route.ts, not about where your business logic lives. That's your problem to solve.

This post is the folder structure guide I wish existed when I started. It covers the full spectrum — from a solo project to a multi-team monorepo — with specific reasoning behind every decision.


TL;DR

  • Next.js only enforces structure inside app/ — everything else is up to you
  • Use src/ to separate app code from config files at root
  • Keep app/ as a pure routing layer; no business logic lives there
  • Route groups (name) let you apply different root layouts without affecting URLs
  • Prefix route-specific code with _ (e.g. _components/) to keep it out of routing
  • Use a hybrid structure: features/ for domain logic (vertical slices), components/ui/ for shared design system
  • Push 'use client' to leaf-level interactive components only — the rest renders on the server
  • Use lib/server/ with import 'server-only' to enforce the server/client boundary at the module level
  • Validate all env vars with Zod at startup (@t3-oss/env-nextjs) — fail fast at build time, not production
  • For monorepos: Turborepo + apps/ and packages/, with explicit package exports (not barrels)

1. The Root: What Goes Where

Before touching app/, get the root right.

my-next-app/
├── next.config.js          // or .mjs / .ts
├── package.json
├── tsconfig.json           // enable strict mode
├── eslint.config.mjs       // ESLint 9 flat config
├── postcss.config.js
├── prettier.config.mjs
├── instrumentation.ts      // OpenTelemetry / monitoring hooks
├── middleware.ts            // EXACTLY here (or src/middleware.ts with src/)
├── .env.example            // commit this
├── .env.local              // never commit -- gitignored
├── .env.development
└── .env.production

The one non-obvious rule: middleware.ts has exactly one valid location — the project root, or src/ if you use the src/ wrapper. It can't go anywhere else. Next.js won't pick it up.


2. Use src/ — It Earns Its Extra Directory Level

// With src/ -- clean separation
my-next-app/
├── next.config.js     ← config
├── middleware.ts      ← edge runtime
├── src/               ← all application code
│   ├── app/
│   ├── components/
│   └── lib/
└── public/

// Without src/ -- config files mix with application code
my-next-app/
├── next.config.js
├── app/
├── components/
├── lib/
└── public/

The src/ wrapper costs nothing. In return, config files at root never get confused with application code. Every non-trivial project should use it.


3. The app/ Directory: Routing, Not Business Logic

app/ is a routing layer. If you're putting business logic directly in page.tsx files, you're making your code untestable and your routes harder to understand.

Reserved filenames per route segment

FileWhat it does
layout.tsxPersistent UI wrapping children (doesn't re-render on navigation)
page.tsxThe public route — only file that exposes a URL
loading.tsxSuspense-based skeleton while data loads
error.tsxError boundary scoped to this segment
not-found.tsx404 for this segment
route.tsAPI endpoint — replaces the old pages/api/
template.tsxLike layout.tsx but re-renders on every navigation
default.tsxRequired fallback for unmatched parallel route slots

The render order matters for debugging: layout → template → error → loading → not-found → page.

Route Groups: Multiple Root Layouts in One App

Route groups use a (name) folder that's invisible in the URL. This is the mechanism for applying completely different layouts to different sections of your app.

// app/ -- three layout contexts, zero URL impact
app/
├── (marketing)/
│   ├── layout.tsx       ← simple header/footer, no auth
│   ├── page.tsx         ← maps to /
│   ├── about/page.tsx   ← maps to /about
│   └── pricing/page.tsx ← maps to /pricing
│
├── (auth)/
│   ├── layout.tsx       ← centered card, no nav
│   ├── login/page.tsx   ← maps to /login
│   └── register/page.tsx
│
└── (app)/
    ├── layout.tsx       ← sidebar + header, auth-required
    ├── dashboard/page.tsx
    └── settings/page.tsx

Without route groups, you'd need a single root layout that awkwardly renders different nav states based on the current path. Route groups eliminate that conditional logic entirely.

Private Folders: Keep app/ Clean

Prefix with _ to exclude a folder from Next.js routing. Use this for code that's scoped to a route but doesn't belong in the global component library:

// app/dashboard/ -- private folders keep route-specific code colocated
app/dashboard/
├── page.tsx
├── loading.tsx
├── error.tsx
├── _components/          ← private -- only dashboard uses these
│   ├── stats-card.tsx
│   └── recent-activity.tsx
└── _lib/                 ← private -- actions, services, loaders
    ├── dashboard.actions.ts
    ├── dashboard.service.ts
    └── dashboard.loader.ts

If stats-card.tsx only exists to render on the dashboard, it doesn't belong in src/components/. Private folders make that explicit.


4. The Full Structure: Production-Ready

Here's the complete structure for a real app — auth, dashboard, API routes, feature modules, the works:

my-next-app/
├── public/
│   ├── images/
│   ├── fonts/
│   └── icons/
│
├── src/
│   ├── app/
│   │   ├── layout.tsx                    ← root layout: html, body, providers
│   │   ├── page.tsx
│   │   ├── not-found.tsx
│   │   ├── global-error.tsx
│   │   ├── sitemap.ts                    ← auto-generated XML sitemap
│   │   ├── robots.ts                     ← auto-generated robots.txt
│   │   │
│   │   ├── (marketing)/
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx
│   │   │   ├── about/page.tsx
│   │   │   └── pricing/page.tsx
│   │   │
│   │   ├── (auth)/
│   │   │   ├── layout.tsx
│   │   │   ├── login/page.tsx
│   │   │   └── register/page.tsx
│   │   │
│   │   ├── (app)/
│   │   │   ├── layout.tsx
│   │   │   ├── dashboard/
│   │   │   │   ├── page.tsx
│   │   │   │   ├── loading.tsx
│   │   │   │   ├── error.tsx
│   │   │   │   ├── _components/
│   │   │   │   │   ├── stats-card.tsx
│   │   │   │   │   └── recent-activity.tsx
│   │   │   │   └── _lib/
│   │   │   │       ├── dashboard.actions.ts
│   │   │   │       └── dashboard.service.ts
│   │   │   └── settings/
│   │   │       ├── page.tsx
│   │   │       └── _components/
│   │   │
│   │   └── api/
│   │       ├── auth/[...nextauth]/route.ts
│   │       └── webhooks/stripe/route.ts
│   │
│   ├── components/
│   │   ├── ui/                           ← primitives: no business logic
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── dialog.tsx
│   │   │   └── index.ts
│   │   ├── layout/                       ← structural components
│   │   │   ├── header.tsx
│   │   │   ├── footer.tsx
│   │   │   └── sidebar.tsx
│   │   └── shared/                       ← cross-feature composites
│   │       ├── data-table.tsx
│   │       └── empty-state.tsx
│   │
│   ├── features/                         ← vertical slices
│   │   ├── auth/
│   │   │   ├── components/
│   │   │   │   ├── login-form.tsx
│   │   │   │   └── register-form.tsx
│   │   │   ├── hooks/
│   │   │   │   └── use-auth.ts
│   │   │   ├── services/
│   │   │   │   └── auth.service.ts
│   │   │   ├── stores/
│   │   │   │   └── auth.store.ts
│   │   │   ├── types/
│   │   │   │   └── auth.types.ts
│   │   │   ├── schemas/
│   │   │   │   └── auth.schema.ts        ← Zod validation schemas
│   │   │   └── index.ts                  ← public API -- only import from here
│   │   ├── blog/
│   │   └── billing/
│   │
│   ├── hooks/                            ← global hooks only (see promotion rule)
│   │   ├── use-debounce.ts
│   │   ├── use-media-query.ts
│   │   └── use-local-storage.ts
│   │
│   ├── lib/
│   │   ├── server/                       ← import 'server-only' at top
│   │   │   ├── db.ts
│   │   │   └── stripe.ts
│   │   ├── auth/
│   │   │   └── config.ts
│   │   ├── i18n/
│   │   │   ├── config.ts
│   │   │   └── routing.ts
│   │   ├── seo/
│   │   │   └── metadata.ts
│   │   ├── animations/
│   │   │   └── variants.ts
│   │   └── utils.ts                      ← pure helpers: cn(), formatDate()
│   │
│   ├── stores/                           ← global state only
│   │   ├── ui.store.ts
│   │   └── user.store.ts
│   │
│   ├── types/
│   │   ├── api.types.ts
│   │   └── common.types.ts
│   │
│   ├── constants/
│   │   ├── routes.ts
│   │   ├── api.ts
│   │   └── query-keys.ts
│   │
│   ├── config/
│   │   └── env.ts                        ← Zod-validated env (t3-env)
│   │
│   ├── styles/
│   │   └── globals.css
│   │
│   └── providers/
│       ├── app-providers.tsx             ← composes all providers
│       ├── theme-provider.tsx            ← 'use client'
│       └── query-provider.tsx            ← 'use client'
│
├── e2e/
│   ├── auth.spec.ts
│   └── playwright.config.ts
│
└── middleware.ts

5. Components: The Three-Tier Model

Flat components/ folders with 200 files are the most common structural failure in Next.js projects. The fix is a three-tier hierarchy:

components/
├── ui/           ← design system primitives (no business logic)
├── layout/       ← structural chrome (header, footer, sidebar, nav)
└── shared/       ← cross-feature composites (DataTable, EmptyState, Pagination)

ui/ contains things like Button, Input, Badge, Dialog — components that could ship in a design system package. They take props, render UI, have no opinions about your domain.

layout/ contains structural chrome: Header, Footer, Sidebar, Nav. These may reference your brand or routing constants, but have no feature-specific logic.

shared/ contains composites that multiple features use: a DataTable that knows about pagination, an EmptyState with a CTA, a file upload component. Business logic is limited and generic.

Colocate your tests and stories

When a component has tests and stories, put them in the same folder:

// components/ui/Button/
button.tsx
button.test.tsx       ← Vitest unit test
button.stories.tsx    ← Storybook story

When you delete a component, you delete one folder. Nothing orphaned.

Where Atomic Design fits (and where it breaks)

Atomic Design (atoms → molecules → organisms → templates) works for pure design systems. In a Next.js app with feature modules, the templates level conflicts with app/'s layout system, and the line between organisms and molecules creates constant debates. Most teams that try full Atomic Design in Next.js end up with a simplified version: atoms (= ui/), molecules/organisms collapsed into shared/, features handling everything domain-specific.


6. Features: Vertical Slices with Hard Boundaries

Type-based flat structures (components/, hooks/, services/ at the same level) feel organized until your codebase has 15 features. Then adding a new feature means touching 6 directories. Deleting a feature means hunting across the entire codebase.

Feature-based vertical slices solve this:

features/
└── auth/
    ├── components/     ← UI for this feature
    ├── hooks/          ← state and logic hooks
    ├── services/       ← API calls and business logic
    ├── stores/         ← feature-scoped state
    ├── types/          ← feature types
    ├── schemas/        ← Zod validation schemas
    └── index.ts        ← PUBLIC API -- the only import path other features use

The index.ts is the key. It's the contract between this feature and the rest of the app:

// features/auth/index.ts
export { LoginForm } from "./components/login-form";
export { RegisterForm } from "./components/register-form";
export { useAuth } from "./hooks/use-auth";
export type { User, Session } from "./types/auth.types";

// Don't export: auth.service.ts, auth.store.ts internals
// These are implementation details -- other features don't need them

Other features import from '@/features/auth', never from '@/features/auth/services/auth.service'. When you refactor internals, the public API doesn't change. No shotgun surgery across the codebase.

The Promotion Rule

Don't pre-emptively abstract. A hook starts inside features/auth/hooks/. It moves to global hooks/ only when a second feature needs it. Same for utilities, services, and types. This prevents the over-engineered shared layer that nobody knows is safe to change.


7. lib/ vs. utils/ — Stop Conflating These

Every codebase eventually creates a utils.ts that becomes a 2000-line dumping ground. The problem is conflating two different categories:

FolderContainsCharacteristics
lib/Integrations, clients, setupSide effects, external dependencies, config
utils.tsPure helper functionsNo side effects, no dependencies, easily testable

lib/ is where you initialize Prisma, configure NextAuth, set up your HTTP client, and wire up Stripe. utils.ts is where cn(), formatDate(), slugify(), and clamp() live.

lib/
├── server/           ← import 'server-only' -- hard enforced boundary
│   ├── db.ts         ← Prisma client init
│   └── stripe.ts     ← Stripe SDK (never ships to browser)
├── auth/
│   └── config.ts     ← NextAuth / Lucia config
├── i18n/
│   ├── config.ts
│   └── routing.ts
├── seo/
│   └── metadata.ts   ← reusable generateMetadata() helpers
├── animations/
│   └── variants.ts   ← Framer Motion variant presets
└── utils.ts          ← cn(), formatDate(), slugify()

8. Server vs. Client: Push 'use client' to the Leaves

In App Router, every component is a Server Component by default. 'use client' opts a component and everything it imports into the client bundle. The mistake most teams make is sprinkling 'use client' broadly because it "just works."

The cost: client-side JavaScript bundle size. The benefit of being selective: the vast majority of your UI renders on the server, with no hydration cost.

// Bad: entire dashboard is a client component
// app/dashboard/_components/dashboard-view.tsx
'use client';

export function DashboardView({ stats, activity }) {
  return (
    <div>
      <StatsGrid stats={stats} />      {/* doesn't need onClick */}
      <ActivityFeed items={activity} /> {/* doesn't need onClick */}
      <RefreshButton />                 {/* this is the only interactive part */}
    </div>
  );
}
// Good: push 'use client' to the interactive leaf
// app/dashboard/_components/stats-grid.tsx -- Server Component (default)
export function StatsGrid({ stats }) { ... }

// app/dashboard/_components/activity-feed.tsx -- Server Component (default)
export function ActivityFeed({ items }) { ... }

// app/dashboard/_components/refresh-button.tsx -- Client Component (needs onClick)
'use client';
export function RefreshButton() {
  return <button onClick={() => router.refresh()}>Refresh</button>;
}

Enforce the server boundary at the module level

// lib/server/db.ts
import "server-only"; // build error if this file is imported in a Client Component

import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();

import 'server-only' (a tiny npm package) causes a build error if the module is accidentally imported in client code. Your database credentials never ship to the browser, enforced by the build system — not by code review.

Thin Actions, Thick Services

Server Actions are a great transport mechanism. They're a bad place for business logic:

// Bad: business logic crammed into action
// app/dashboard/_lib/dashboard.actions.ts
'use server';
export async function createBoardAction(formData: FormData) {
  const name = formData.get('name') as string;
  if (!name || name.length < 3) throw new Error('Name too short');
  const user = await getCurrentUser();
  if (!user) throw new Error('Unauthorized');
  const board = await db.board.create({ data: { name, userId: user.id } });
  revalidatePath('/dashboard');
  return board;
}

// Good: action is thin plumbing, service has the logic
// app/dashboard/_lib/dashboard.actions.ts
'use server';
export async function createBoardAction(input: CreateBoardInput) {
  return boardService.createBoard(input);
}

// app/dashboard/_lib/dashboard.service.ts -- testable, no Next.js dependency
export class BoardService {
  async createBoard(input: CreateBoardInput) {
    const validated = createBoardSchema.parse(input);
    const user = await getCurrentUser();
    if (!user) throw new UnauthorizedError();
    const board = await db.board.create({ ... });
    return board;
  }
}

The service is a plain TypeScript class. You can unit test it without spinning up Next.js. The action is just a thin adapter.


9. State Management: The Scope Ladder

Don't reach for a global store before you need one. The ladder:

useState → useContext → feature-scoped store → global store

In App Router, the most important shift is: server data doesn't need client state. If it comes from a database, fetch it in a Server Component and pass it as props. Use TanStack Query only for client-side mutations and optimistic updates. Use global stores for UI state: sidebar open/closed, active modal, user preferences, theme.

Zustand (current community default)

// stores/ui.store.ts
import { create } from "zustand";

interface UIState {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
}

export const useUIStore = create<UIState>((set) => ({
  sidebarOpen: false,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));

Feature-scoped Zustand stores live in features/[name]/stores/. Global stores (UI state, user session) live in src/stores/.

Redux Toolkit layout

stores/
├── store.ts        ← configureStore + root reducer
├── hooks.ts        ← typed useAppDispatch + useAppSelector
└── slices/
    ├── auth.slice.ts
    ├── ui.slice.ts
    └── cart.slice.ts

Jotai (atomic model)

stores/
└── atoms/
    ├── user.atom.ts
    ├── theme.atom.ts
    └── cart.atom.ts

10. Environment Variables: Fail at Build Time, Not in Production

Scattered process.env.STRIPE_KEY calls with no validation are a production incident waiting to happen. The fix: validate everything at startup with Zod.

// src/config/env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    NEXTAUTH_SECRET: z.string().min(32),
    STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
  },
});

If DATABASE_URL is missing or malformed, the build fails immediately. You catch the misconfiguration in CI, not when a customer hits a checkout flow. Import env from this file everywhere — never process.env directly.


11. Special Use Cases: Where Things Live

Auth

lib/auth/config.ts                       ← NextAuth / Lucia / Clerk config
app/api/auth/[...nextauth]/route.ts      ← route handler
middleware.ts                            ← redirect unauthenticated users
features/auth/components/               ← LoginForm, RegisterForm
features/auth/hooks/use-auth.ts         ← session access hook

i18n (next-intl)

lib/i18n/config.ts            ← locale list, default locale
lib/i18n/routing.ts           ← createLocalizedPathnamesNavigation
messages/                     ← translation files at project root
├── en.json
└── de.json
app/[locale]/                 ← dynamic locale segment wraps all routes
    ├── layout.tsx             ← sets lang attribute, provides messages
    └── page.tsx
middleware.ts                 ← locale detection + redirect

SEO

lib/seo/metadata.ts           ← generateMetadata() helpers
lib/seo/structured-data.ts    ← JSON-LD schema builders

// Per-route:
app/about/
├── page.tsx                  ← export const metadata = { ... }
└── opengraph-image.tsx       ← generated OG image (Next.js ImageResponse)

// Global:
app/sitemap.ts                ← returns SitemapFile
app/robots.ts                 ← returns RobotsFile

Analytics and Third-Party

lib/
├── analytics/
│   ├── posthog.ts
│   └── gtm.ts
├── monitoring/
│   └── sentry.ts             ← Sentry initialization
└── payments/
    └── stripe.ts             ← import 'server-only' -- never ships to browser

12. Monorepo with Turborepo

Once you have multiple Next.js apps sharing code, Turborepo is the current standard:

my-turborepo/
├── apps/
│   ├── web/                  ← @acme/web -- main Next.js app
│   └── admin/                ← @acme/admin -- internal tools app
├── packages/
│   ├── ui/                   ← @acme/ui -- shared component library
│   ├── database/             ← @acme/database -- Prisma schema + client
│   ├── auth/                 ← @acme/auth
│   └── config/               ← shared eslint, ts, tailwind configs
├── pnpm-workspace.yaml
└── turbo.json

The critical detail most guides miss: use explicit package exports, not barrel files:

// packages/ui/package.json
{
  "exports": {
    "./button": "./src/button.tsx",
    "./input": "./src/input.tsx"
  }
}
// Good: tree-shakeable -- bundler can eliminate unused exports
import { Button } from "@acme/ui/button";

// Bad: barrel import -- bundler pulls the entire package into the bundle
import { Button } from "@acme/ui";

With a shared ui package, barrel imports mean every app that imports one component pays the cost of the entire package. Explicit exports fix this.


13. Anti-Patterns That Will Haunt You

Anti-PatternWhat to Do Instead
200+ files flat in components/Three tiers: ui/, layout/, shared/
7+ folder nesting levelsMax 3-4 levels — flatten aggressively
Monolithic utils.ts over 500 linesSplit: utils/formatting.ts, utils/validation.ts
'use client' on page-level componentsPush it to interactive leaf components only
Server secrets in shared lib/utils.tslib/server/ with import 'server-only'
All types in global types/Feature-local types; global types/ for shared contracts only
Hardcoded process.env.X everywhereCentralize + validate in config/env.ts
No index.ts for feature foldersExplicit public API — prevents accidental deep imports
Tests in __tests__/ far from sourceColocate *.test.tsx next to source files
Business logic in Server ActionsThin actions, thick service classes

14. Which Structure for Your Project?

Solo project / early startup (1-2 devs, under 10 features) Skip features/. Use a flat type-based structure: components/, hooks/, lib/, services/. Promote to feature-based when the flat structure starts to hurt.

Growing team (3-10 devs, 10-30 features) The hybrid structure in this post. features/ for domain logic, components/ui/ for the design system, lib/ for integrations. This is the right default.

Multi-team / platform (10+ devs, 30+ features) Full feature-based with strict index.ts public APIs + Turborepo monorepo. Consider separate packages for ui, database, and auth. Enforce module boundaries with ESLint's import/no-restricted-paths.


Where to Go From Here

The structure above works. Plenty of teams use variations of it in production. The specific folder names matter less than the principles:

  • app/ is routing only
  • Route groups give you multiple layout contexts without URL pollution
  • Features are vertical slices with explicit public APIs
  • Server code lives behind import 'server-only'
  • Env vars are validated at build time, not runtime
Tags:nextjsarchitectureapp routertypescriptmonorepo