Skip to content
·11 min read

Structuring Next.js Projects at Scale: What I Learned the Hard Way

Hard-won lessons on organizing Next.js codebases that don't collapse under their own weight. Feature-based architecture, route groups, server/client boundaries, barrel file traps, and a real folder structure you can steal.

Share:X / TwitterLinkedIn

Every Next.js project starts clean. A few pages, a components folder, maybe a lib/utils.ts. You feel productive. You feel organized.

Then six months pass. Three developers join. The components folder has 140 files. Someone created utils2.ts because utils.ts was 800 lines. Nobody knows which layout wraps which route. Your build takes four minutes and you're not sure why.

I've been through this cycle multiple times. What follows is what I actually do now, after learning most of these lessons by shipping the wrong thing first.

The Default Structure Falls Apart Fast#

The Next.js docs suggest a project structure. It's fine. It works for a personal blog or a small tool. But the moment you have more than two developers touching the same codebase, the "type-based" approach starts to crack:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 more files
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     ← the beginning of the end
  app/
    page.tsx
    dashboard/
      page.tsx

The problem isn't that this is "wrong." It's that it scales linearly with features but not with comprehension. When a new developer needs to work on invoices, they have to open components/, hooks/, utils/, types/, and app/ simultaneously. The invoice feature is scattered across five directories. There's no single place that says "this is what invoicing looks like."

I watched a team of four developers spend a full sprint refactoring into this exact structure, convinced it was the "clean" way. Within three months they were back to being confused, just with more folders.

Feature-Based Colocation Wins. Every Time.#

The pattern that actually survived contact with a real team is feature-based colocation. The idea is simple: everything related to a feature lives together.

Not "all components in one folder and all hooks in another." The opposite. All the components, hooks, types, and utilities for invoicing live inside features/invoicing/. Everything for auth lives inside features/auth/.

typescript
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
 
export function useInvoiceActions(invoice: Invoice) {
  const markAsPaid = useCallback(async () => {
    await invoiceApi.updateStatus(invoice.id, "paid");
  }, [invoice.id]);
 
  const duplicate = useCallback(async () => {
    const newNumber = formatInvoiceNumber(Date.now());
    await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
  }, [invoice]);
 
  return { markAsPaid, duplicate };
}

Notice the imports. They're all relative, all within the feature. This hook doesn't reach into some global hooks/ folder or a shared utils/format.ts. It uses the invoicing feature's own formatting logic because invoice number formatting is an invoicing concern, not a global one.

The counterintuitive part: this means you'll have some duplication. Your invoicing feature might have a format.ts and your billing feature might also have a format.ts. That's fine. That's actually better than a shared formatters.ts that 14 features depend on and nobody can safely change.

I resisted this for a long time. DRY was gospel. But I've seen shared utility files become the most dangerous files in a codebase, files where a "small fix" for one feature breaks three others. Colocation with controlled duplication is more maintainable than forced sharing.

The Folder Structure I Actually Use#

Here's the full tree. This has been battle-tested across two production applications with 4-8 person teams:

src/
├── app/
│   ├── (public)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── pricing/
│   │   │   └── page.tsx
│   │   └── blog/
│   │       ├── page.tsx
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (auth)/
│   │   ├── layout.tsx
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── invoices/
│   │   │   ├── page.tsx
│   │   │   ├── [id]/
│   │   │   │   └── page.tsx
│   │   │   └── new/
│   │   │       └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts
│   ├── layout.tsx
│   └── not-found.tsx
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── login-form.tsx
│   │   │   ├── register-form.tsx
│   │   │   └── oauth-buttons.tsx
│   │   ├── actions/
│   │   │   ├── login.ts
│   │   │   └── register.ts
│   │   ├── hooks/
│   │   │   └── use-session.ts
│   │   ├── types.ts
│   │   └── constants.ts
│   ├── invoicing/
│   │   ├── components/
│   │   │   ├── invoice-table.tsx
│   │   │   ├── invoice-row.tsx
│   │   │   ├── invoice-form.tsx
│   │   │   └── invoice-pdf.tsx
│   │   ├── actions/
│   │   │   ├── create-invoice.ts
│   │   │   └── update-status.ts
│   │   ├── hooks/
│   │   │   └── use-invoice-actions.ts
│   │   ├── api/
│   │   │   └── client.ts
│   │   ├── utils/
│   │   │   └── format.ts
│   │   └── types.ts
│   └── billing/
│       ├── components/
│       ├── actions/
│       ├── hooks/
│       └── types.ts
├── shared/
│   ├── components/
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── modal.tsx
│   │   │   └── toast.tsx
│   │   └── layout/
│   │       ├── header.tsx
│   │       ├── footer.tsx
│   │       └── sidebar.tsx
│   ├── hooks/
│   │   ├── use-debounce.ts
│   │   └── use-media-query.ts
│   ├── lib/
│   │   ├── db.ts
│   │   ├── auth.ts
│   │   ├── stripe.ts
│   │   └── email.ts
│   └── types/
│       └── global.ts
└── config/
    ├── site.ts
    └── navigation.ts

A few things to notice:

  • app/ only contains routing logic. Page files are thin. They import from features/ and compose. A page file should be 20-40 lines, max.
  • features/ is where the real code lives. Each feature is self-contained. You can delete features/invoicing/ and nothing else breaks (except the pages that import from it, which is exactly the coupling you want).
  • shared/ is not a dumping ground. It has strict subcategories. More on this below.

Route Groups: More Than Just Organization#

Route groups, the (parenthesized) folders, are one of the most underused features in the App Router. They don't affect the URL at all. (dashboard)/invoices/page.tsx renders at /invoices, not /dashboard/invoices.

But the real power is layout isolation. Each route group gets its own layout.tsx:

typescript
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
typescript
// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
 
export default function PublicLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

The auth check happens once at the layout level, not in every page. Public pages get a completely different shell. Auth pages get no layout at all, just a centered card. This is clean, explicit, and hard to mess up.

I've seen teams put auth checks inside middleware, inside individual pages, inside components, all at the same time. Pick one place. For most apps, the route group layout is that place.

The shared/ Folder Is Not utils/#

The fastest way to create a maintenance nightmare is a lib/utils.ts file. It starts small. Then it becomes the junk drawer of the codebase, where every function that doesn't have an obvious home ends up.

Here's my rule: the shared/ folder requires justification. Something goes into shared/ only if:

  1. It's used by 3+ features, and
  2. It's genuinely generic (not business-logic wearing a generic disguise)

A date formatting function that formats invoice due dates? That goes in features/invoicing/utils/. A date formatting function that takes a Date and returns a locale string? That can go in shared/.

The shared/lib/ subfolder is specifically for third-party integrations and infrastructure: database clients, auth libraries, payment providers. These are things the whole application depends on but no single feature owns.

typescript
// shared/lib/db.ts — this belongs in shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
 
// features/invoicing/utils/format.ts — this does NOT belong in shared
export function formatInvoiceNumber(timestamp: number): string {
  const date = new Date(timestamp);
  const year = date.getFullYear();
  const seq = Math.floor(Math.random() * 9000) + 1000;
  return `INV-${year}-${seq}`;
}

If you're reaching for shared/utils/ and can't name the file something more specific than helpers.ts, stop. That code probably belongs inside a feature.

Server vs. Client Components: Draw the Line Deliberately#

Here's a pattern I see constantly in codebases that have performance problems:

typescript
// This looks reasonable but is a mistake
"use client";
 
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
 
export default function InvoicesPage() {
  const { data } = useInvoiceActions();
  return (
    <div>
      <InvoiceFilters />
      <InvoiceTable invoices={data} />
    </div>
  );
}

The page is marked "use client". Now everything it imports, InvoiceTable, InvoiceFilters, the hook, every dependency of every dependency, is in the client bundle. Your 200-row table that could have been rendered on the server with zero JavaScript is now shipping to the browser.

The fix is to push "use client" down to the leaf components that actually need it:

typescript
// app/(dashboard)/invoices/page.tsx — Server Component (no directive)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
 
export default async function InvoicesPage() {
  const invoices = await db.query.invoices.findMany({
    orderBy: (inv, { desc }) => [desc(inv.createdAt)],
  });
 
  return (
    <div>
      <InvoiceFilters />          {/* Client Component — has interactive filters */}
      <InvoiceTable data={invoices} /> {/* Server Component — just renders rows */}
    </div>
  );
}
typescript
// features/invoicing/components/invoice-filters.tsx
"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
 
export function InvoiceFilters() {
  const router = useRouter();
  const params = useSearchParams();
 
  const setStatus = (status: string) => {
    const next = new URLSearchParams(params.toString());
    next.set("status", status);
    router.push(`?${next.toString()}`);
  };
 
  return (
    <div className="flex gap-2">
      {["all", "draft", "sent", "paid"].map((s) => (
        <button
          key={s}
          onClick={() => setStatus(s)}
          className={params.get("status") === s ? "font-bold" : ""}
        >
          {s}
        </button>
      ))}
    </div>
  );
}

The rule I follow: every component is a Server Component until it needs useState, useEffect, event handlers, or browser APIs. Then, and only then, add "use client", and try to make that component as small as possible.

The children pattern is your best friend here. A Client Component can receive Server Components as children:

typescript
"use client";
 
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}  {/* children can be Server Components */}
    </div>
  );
}

This is how you keep 90% of your component tree on the server while still having interactivity where it matters.

Barrel Files: The Silent Build Killer#

I used to put an index.ts in every folder. Clean imports, right?

typescript
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";

Then I could import like this:

typescript
import { InvoiceTable } from "@/features/invoicing/components";

Looks nice. Here's the problem: when the bundler sees that import, it has to evaluate the entire barrel file to figure out what InvoiceTable is. That means it loads InvoiceRow, InvoiceForm, and InvoicePdf too, even if you only needed the table.

In one project, removing internal barrel files dropped module count from 11,000 to 3,500 during development. The dev server went from 5-10 second page loads to under 2 seconds. First load JavaScript dropped from over a megabyte to around 200 KB.

Vercel built optimizePackageImports in next.config.ts to handle this for third-party packages from node_modules. But it doesn't fix your own barrel files. For your own code, the answer is simpler: don't use them.

typescript
// Do this
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// Not this
import { InvoiceTable } from "@/features/invoicing/components";

Yes, the import path is longer. Your build is faster, your bundles are smaller, and your dev server doesn't choke. That's a trade I'll take every time.

The one exception: a feature's public API. If features/invoicing/ exposes a single index.ts at the feature root level with just the 2-3 things other features are allowed to import, that's fine. It acts as a boundary, not a convenience shortcut. But barrel files inside subfolders like components/index.ts or hooks/index.ts? Kill them.

Thin Pages, Fat Features#

The most important architectural rule I follow is this: page files are wiring, not logic.

A page file in app/ should do three things:

  1. Fetch data (if it's a Server Component)
  2. Import feature components
  3. Compose them together

That's it. No business logic. No complex conditional rendering. No 200-line JSX trees. If a page file is growing past 40 lines, you're putting feature code in the wrong place.

typescript
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function InvoicePage({ params }: Props) {
  const { id } = await params;
  const invoice = await db.query.invoices.findFirst({
    where: (inv, { eq }) => eq(inv.id, id),
  });
 
  if (!invoice) notFound();
 
  return <InvoiceDetail invoice={invoice} />;
}

Twelve lines. The page fetches the data it needs, handles the not-found case, and delegates everything else to the feature. If the invoice detail UI changes, you edit features/invoicing/, not app/. If the route changes, you move the page file, and the feature code doesn't care.

This is how you get a codebase where 15 developers can work simultaneously without stepping on each other.

The Uncomfortable Truth#

There's no folder structure that prevents bad code. I've seen beautifully organized projects full of terrible abstractions, and messy flat structures that were easy to work in because the code itself was clear.

Structure is a tool, not a goal. The feature-based approach I've described here works because it optimizes for the thing that matters most at scale: can a developer who's never seen this codebase find what they need and change it without breaking something else?

If your current structure answers "yes" to that question, don't refactor it because a blog post told you to, even this one.

But if your developers are spending more time navigating the codebase than writing code, if "where does this go?" is a recurring question in PRs, if a change to billing somehow breaks the dashboard, then your structure is fighting you. And the patterns above are how I've fixed that, every time.

Related Posts