सामग्री पर जाएं
·13 मिनट पढ़ने का समय

बड़े पैमाने पर Next.js प्रोजेक्ट्स को Structure करना: जो मैंने कठिन तरीके से सीखा

Next.js codebases को organize करने के कठिन अनुभव से सीखे गए सबक जो अपने ही बोझ से न गिरें। Feature-based architecture, route groups, server/client boundaries, barrel file traps, और एक असली folder structure।

साझा करें:X / TwitterLinkedIn

हर Next.js प्रोजेक्ट साफ-सुथरा शुरू होता है। कुछ pages, एक components folder, शायद एक lib/utils.ts। आप productive महसूस करते हैं। आप organized महसूस करते हैं।

फिर छह महीने बीत जाते हैं। तीन developers जुड़ते हैं। Components folder में 140 files हैं। किसी ने utils2.ts बना दिया क्योंकि utils.ts 800 lines का था। किसी को नहीं पता कि कौन सा layout किस route को wrap करता है। आपका build चार मिनट लेता है और आपको पक्का नहीं पता क्यों।

मैं इस चक्र से कई बार गुज़र चुका हूं। जो आगे है वह मैं अब वास्तव में करता हूं, इनमें से ज़्यादातर सबक पहले गलत चीज़ ship करके सीखने के बाद।

Default Structure जल्दी टूट जाता है#

Next.js docs एक project structure suggest करते हैं। यह ठीक है। यह एक personal blog या छोटे tool के लिए काम करता है। लेकिन जिस पल दो से ज़्यादा developers एक ही codebase पर काम करते हैं, "type-based" approach में दरारें आने लगती हैं:

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

समस्या यह नहीं कि यह "गलत" है। समस्या यह है कि यह features के साथ linearly scale करता है लेकिन समझ के साथ नहीं। जब एक नए developer को invoices पर काम करना होता है, उन्हें एक साथ components/, hooks/, utils/, types/, और app/ खोलने पड़ते हैं। Invoice feature पांच directories में बिखरा हुआ है। कोई एक जगह नहीं जो कहे "invoicing ऐसी दिखती है।"

मैंने चार developers की एक टीम को एक पूरा sprint इसी structure में refactor करते देखा, इस विश्वास के साथ कि यह "clean" तरीका है। तीन महीने के भीतर वे फिर से confused थे, बस ज़्यादा folders के साथ।

Feature-Based Colocation जीतता है। हर बार।#

वह pattern जो वास्तव में एक real team के संपर्क में टिका है, feature-based colocation है। विचार सरल है: एक feature से related सब कुछ एक साथ रहता है।

"सारे components एक folder में और सारे hooks दूसरे में" नहीं। इसका उल्टा। Invoicing के सारे components, hooks, types, और utilities features/invoicing/ के अंदर रहते हैं। Auth से जुड़ी हर चीज़ 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 };
}

Imports पर ध्यान दें। वे सब relative हैं, सब feature के अंदर। यह hook किसी global hooks/ folder या shared utils/format.ts तक नहीं पहुंचता। यह invoicing feature की अपनी formatting logic का उपयोग करता है क्योंकि invoice number formatting एक invoicing concern है, global नहीं।

प्रतिकूल बात: इसका मतलब है कि आपके पास कुछ duplication होगी। आपके invoicing feature में एक format.ts हो सकता है और आपके billing feature में भी एक format.ts हो सकता है। यह ठीक है। यह वास्तव में एक shared formatters.ts से बेहतर है जिस पर 14 features निर्भर हैं और कोई भी सुरक्षित रूप से बदल नहीं सकता।

मैंने लंबे समय तक इसका विरोध किया। DRY एक सिद्धांत था। लेकिन मैंने shared utility files को एक codebase में सबसे खतरनाक files बनते देखा है — ऐसी files जहां एक feature के लिए "छोटा fix" तीन दूसरों को तोड़ देता है। Controlled duplication के साथ colocation, forced sharing से ज़्यादा maintainable है।

वह Folder Structure जो मैं वास्तव में उपयोग करता हूं#

यह रहा पूरा tree। यह 4-8 लोगों की टीमों के साथ दो production applications में battle-tested है:

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

कुछ चीज़ें ध्यान देने योग्य:

  • app/ में केवल routing logic है। Page files thin हैं। वे features/ से import करती हैं और compose करती हैं। एक page file अधिकतम 20-40 lines की होनी चाहिए।
  • features/ वह जगह है जहां असली code रहता है। हर feature self-contained है। आप features/invoicing/ delete कर सकते हैं और कुछ और नहीं टूटता (उन pages को छोड़कर जो इससे import करते हैं, जो ठीक वही coupling है जो आप चाहते हैं)।
  • shared/ एक dumping ground नहीं है। इसकी strict subcategories हैं। इसके बारे में नीचे और।

Route Groups: सिर्फ Organization से कहीं ज़्यादा#

Route groups, (parenthesized) folders, App Router में सबसे कम उपयोग की जाने वाली features में से एक हैं। वे URL को बिल्कुल affect नहीं करते। (dashboard)/invoices/page.tsx /invoices पर render होता है, /dashboard/invoices पर नहीं।

लेकिन असली ताकत layout isolation है। हर route group को अपना 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 />
    </>
  );
}

Auth check layout level पर एक बार होता है, हर page में नहीं। Public pages को पूरी तरह अलग shell मिलता है। Auth pages को कोई layout नहीं मिलता — बस एक centered card। यह clean, explicit, और गड़बड़ करना मुश्किल है।

मैंने टीमों को auth checks middleware में, individual pages में, components में — सब एक ही समय पर रखते देखा है। एक जगह चुनें। ज़्यादातर apps के लिए, route group layout वह जगह है।

shared/ Folder utils/ नहीं है#

Maintenance nightmare बनाने का सबसे तेज़ तरीका lib/utils.ts file है। यह छोटी शुरू होती है। फिर यह codebase का junk drawer बन जाती है — जहां हर वह function जिसका कोई obvious घर नहीं है, जमा हो जाता है।

यह रहा मेरा नियम: shared/ folder को justification चाहिए। कुछ shared/ में तभी जाता है जब:

  1. यह 3+ features द्वारा उपयोग किया जाता है, और
  2. यह सच में generic है (business-logic नहीं जो generic का भेस पहने हो)

एक date formatting function जो invoice due dates format करता है? वह features/invoicing/utils/ में जाता है। एक date formatting function जो एक Date लेता है और locale string return करता है? वह shared/ में जा सकता है।

shared/lib/ subfolder विशेष रूप से third-party integrations और infrastructure के लिए है: database clients, auth libraries, payment providers। ये वे चीज़ें हैं जिन पर पूरा application निर्भर करता है लेकिन कोई एक feature own नहीं करता।

typescript
// shared/lib/db.ts — यह shared में belongs करता है
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 — यह shared में belong नहीं करता
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}`;
}

अगर आप shared/utils/ तक पहुंच रहे हैं और file को helpers.ts से ज़्यादा specific नाम नहीं दे सकते, रुकें। वह code शायद किसी feature के अंदर belongs करता है।

Server vs. Client Components: सीमा जानबूझकर खींचें#

यह एक pattern है जो मैं लगातार उन codebases में देखता हूं जिनमें performance problems हैं:

typescript
// यह reasonable लगता है लेकिन गलती है
"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>
  );
}

Page "use client" से mark किया गया है। अब जो कुछ भी यह import करता है — InvoiceTable, InvoiceFilters, hook, हर dependency की हर dependency — client bundle में है। आपकी 200-row table जो server पर zero JavaScript के साथ render हो सकती थी, अब browser को ship हो रही है।

Fix यह है कि "use client" को नीचे leaf components तक push करें जिन्हें वास्तव में इसकी ज़रूरत है:

typescript
// app/(dashboard)/invoices/page.tsx — Server Component (कोई 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 — interactive filters हैं */}
      <InvoiceTable data={invoices} /> {/* Server Component — बस rows render करता है */}
    </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>
  );
}

जो नियम मैं follow करता हूं: हर component एक Server Component है जब तक उसे useState, useEffect, event handlers, या browser APIs की ज़रूरत न हो। तभी, और केवल तभी, "use client" जोड़ें — और उस component को जितना हो सके छोटा बनाने की कोशिश करें।

Children pattern यहां आपका सबसे अच्छा दोस्त है। एक Client Component children के रूप में Server Components प्राप्त कर सकता है:

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 Server Components हो सकते हैं */}
    </div>
  );
}

इस तरह आप अपने component tree का 90% server पर रखते हैं जबकि जहां ज़रूरत है वहां interactivity बनाए रखते हैं।

Barrel Files: खामोश Build Killer#

मैं हर folder में एक index.ts डालता था। Clean imports, है ना?

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";

फिर मैं इस तरह import कर सकता था:

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

अच्छा लगता है। यह रही समस्या: जब bundler उस import को देखता है, उसे पूरी barrel file evaluate करनी पड़ती है यह पता लगाने के लिए कि InvoiceTable क्या है। इसका मतलब यह InvoiceRow, InvoiceForm, और InvoicePdf भी load करता है, भले ही आपको सिर्फ table चाहिए थी।

एक प्रोजेक्ट में, internal barrel files हटाने से development के दौरान module count 11,000 से 3,500 तक गिर गया। Dev server 5-10 second page loads से 2 seconds से कम हो गया। First load JavaScript एक megabyte से ज़्यादा से लगभग 200 KB तक गिर गया।

Vercel ने next.config.ts में optimizePackageImports बनाया node_modules से third-party packages के लिए। लेकिन यह आपकी अपनी barrel files को fix नहीं करता। अपने code के लिए, जवाब सरल है: उनका उपयोग मत करें।

typescript
// यह करें
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// यह नहीं
import { InvoiceTable } from "@/features/invoicing/components";

हां, import path लंबा है। आपका build तेज़ है, आपके bundles छोटे हैं, और आपका dev server चोक नहीं करता। यह एक trade है जो मैं हर बार लूंगा।

एक अपवाद: एक feature का public API। अगर features/invoicing/ feature root level पर एक single index.ts expose करता है जिसमें सिर्फ वे 2-3 चीज़ें हैं जो दूसरे features import कर सकते हैं, तो ठीक है। यह एक boundary के रूप में काम करता है, convenience shortcut नहीं। लेकिन subfolders जैसे components/index.ts या hooks/index.ts के अंदर barrel files? उन्हें हटा दें।

Thin Pages, Fat Features#

सबसे महत्वपूर्ण architectural नियम जो मैं follow करता हूं यह है: page files wiring हैं, logic नहीं।

app/ में एक page file को तीन काम करने चाहिए:

  1. Data fetch करना (अगर यह Server Component है)
  2. Feature components import करना
  3. उन्हें एक साथ compose करना

बस। कोई business logic नहीं। कोई complex conditional rendering नहीं। कोई 200-line JSX trees नहीं। अगर एक page file 40 lines से आगे बढ़ रही है, तो आप feature code गलत जगह रख रहे हैं।

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} />;
}

बारह lines। Page वह data fetch करता है जो उसे चाहिए, not-found case handle करता है, और बाकी सब feature को delegate करता है। अगर invoice detail UI बदलता है, तो आप features/invoicing/ edit करते हैं, app/ नहीं। अगर route बदलता है, तो आप page file move करते हैं, और feature code को फ़र्क़ नहीं पड़ता।

इस तरह आप एक ऐसा codebase पाते हैं जहां 15 developers एक साथ काम कर सकते हैं बिना एक-दूसरे के रास्ते में आए।

असुविधाजनक सच्चाई#

कोई folder structure खराब code नहीं रोकता। मैंने खूबसूरती से organized projects देखे हैं जो भयानक abstractions से भरे थे, और गंदे flat structures जिनमें काम करना आसान था क्योंकि code खुद clear था।

Structure एक tool है, goal नहीं। जो feature-based approach मैंने यहां describe किया है वह इसलिए काम करता है क्योंकि यह उस चीज़ के लिए optimize करता है जो बड़े पैमाने पर सबसे ज़्यादा मायने रखती है: क्या कोई developer जिसने यह codebase कभी नहीं देखा, वह जो चाहिए वह ढूंढ सकता है और उसे बदल सकता है बिना कुछ और तोड़े?

अगर आपका मौजूदा structure उस सवाल का "हां" में जवाब देता है, तो इसे refactor मत करें सिर्फ इसलिए कि किसी blog post ने कहा — यह वाली भी।

लेकिन अगर आपके developers codebase navigate करने में code लिखने से ज़्यादा समय बिता रहे हैं, अगर "यह कहां जाता है?" PRs में बार-बार पूछा जाने वाला सवाल है, अगर billing में बदलाव किसी तरह dashboard तोड़ देता है — तो आपका structure आपके खिलाफ लड़ रहा है। और ऊपर के patterns वो हैं जिनसे मैंने यह ठीक किया है, हर बार।

संबंधित पोस्ट