Lompat ke konten
·10 menit membaca

Menstrukturkan Proyek Next.js dalam Skala Besar: Apa yang Saya Pelajari dengan Cara Sulit

Pelajaran berharga tentang mengorganisir codebase Next.js agar tidak runtuh karena bebannya sendiri. Arsitektur berbasis fitur, route groups, batasan server/client, jebakan barrel file, dan struktur folder nyata yang bisa Anda tiru.

Bagikan:X / TwitterLinkedIn

Setiap proyek Next.js dimulai dengan bersih. Beberapa halaman, folder components, mungkin sebuah lib/utils.ts. Anda merasa produktif. Anda merasa terorganisir.

Lalu enam bulan berlalu. Tiga developer bergabung. Folder components punya 140 file. Seseorang membuat utils2.ts karena utils.ts sudah 800 baris. Tidak ada yang tahu layout mana yang membungkus route mana. Build Anda memakan waktu empat menit dan Anda tidak yakin mengapa.

Saya sudah melewati siklus ini berkali-kali. Berikut ini adalah apa yang sebenarnya saya lakukan sekarang, setelah mempelajari sebagian besar pelajaran ini dengan cara merilis hal yang salah terlebih dahulu.

Struktur Default Cepat Berantakan#

Dokumentasi Next.js menyarankan sebuah struktur proyek. Tidak masalah. Itu berfungsi untuk blog pribadi atau tool kecil. Tapi begitu Anda punya lebih dari dua developer yang menyentuh codebase yang sama, pendekatan "berbasis tipe" mulai retak:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 file lagi
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     <- awal dari kehancuran
  app/
    page.tsx
    dashboard/
      page.tsx

Masalahnya bukan karena ini "salah." Masalahnya adalah ini berskala secara linear seiring fitur tetapi tidak seiring pemahaman. Ketika developer baru perlu mengerjakan invoice, mereka harus membuka components/, hooks/, utils/, types/, dan app/ secara bersamaan. Fitur invoice tersebar di lima direktori. Tidak ada satu tempat pun yang mengatakan "beginilah tampilan invoicing."

Saya menyaksikan tim empat developer menghabiskan satu sprint penuh untuk refactoring ke struktur persis ini, yakin itu adalah cara yang "bersih." Dalam tiga bulan mereka kembali bingung, hanya saja dengan lebih banyak folder.

Kolokasi Berbasis Fitur Selalu Menang#

Pola yang benar-benar bertahan saat berhadapan dengan tim nyata adalah kolokasi berbasis fitur. Idenya sederhana: segala sesuatu yang berkaitan dengan sebuah fitur tinggal bersama.

Bukan "semua komponen di satu folder dan semua hooks di folder lain." Sebaliknya. Semua komponen, hooks, tipe, dan utilitas untuk invoicing tinggal di dalam features/invoicing/. Semua yang untuk auth tinggal di dalam 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 };
}

Perhatikan import-nya. Semuanya relatif, semuanya di dalam fitur. Hook ini tidak menjangkau ke folder hooks/ global atau utils/format.ts yang shared. Hook ini menggunakan logika formatting milik fitur invoicing sendiri karena format nomor invoice adalah urusan invoicing, bukan urusan global.

Bagian yang kontra-intuitif: ini berarti Anda akan punya beberapa duplikasi. Fitur invoicing Anda mungkin punya format.ts dan fitur billing Anda mungkin juga punya format.ts. Tidak apa-apa. Itu sebenarnya lebih baik daripada formatters.ts yang shared dan digunakan 14 fitur yang tidak bisa diubah siapa pun dengan aman.

Saya menolak ini cukup lama. DRY adalah prinsip sakral. Tapi saya sudah melihat file utilitas shared menjadi file paling berbahaya dalam codebase, file di mana "perbaikan kecil" untuk satu fitur merusak tiga fitur lainnya. Kolokasi dengan duplikasi terkontrol lebih mudah dipelihara daripada sharing yang dipaksakan.

Struktur Folder yang Sebenarnya Saya Gunakan#

Berikut tree lengkapnya. Ini sudah teruji di dua aplikasi production dengan tim 4-8 orang:

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

Beberapa hal yang perlu diperhatikan:

  • app/ hanya berisi logika routing. File page itu tipis. Mereka mengimpor dari features/ dan menyusunnya. File page seharusnya 20-40 baris, maksimal.
  • features/ adalah tempat kode sesungguhnya berada. Setiap fitur berdiri sendiri. Anda bisa menghapus features/invoicing/ dan tidak ada yang rusak (kecuali halaman yang mengimpor darinya, yang merupakan coupling yang memang Anda inginkan).
  • shared/ bukan tempat pembuangan. Folder ini punya subkategori yang ketat. Lebih lanjut tentang ini di bawah.

Route Groups: Lebih dari Sekadar Organisasi#

Route groups, folder (dalam tanda kurung), adalah salah satu fitur yang paling jarang dimanfaatkan di App Router. Mereka tidak memengaruhi URL sama sekali. (dashboard)/invoices/page.tsx di-render di /invoices, bukan /dashboard/invoices.

Tapi kekuatan sesungguhnya adalah isolasi layout. Setiap route group mendapat layout.tsx-nya sendiri:

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

Pengecekan auth terjadi sekali di level layout, bukan di setiap halaman. Halaman publik mendapat shell yang sama sekali berbeda. Halaman auth tidak mendapat layout sama sekali, hanya card yang di-center. Ini bersih, eksplisit, dan sulit untuk salah.

Saya pernah melihat tim menaruh pengecekan auth di dalam middleware, di dalam halaman individual, di dalam komponen, semua secara bersamaan. Pilih satu tempat. Untuk sebagian besar aplikasi, layout route group adalah tempat itu.

Folder shared/ Bukan utils/#

Cara tercepat untuk menciptakan mimpi buruk pemeliharaan adalah file lib/utils.ts. Dimulai kecil. Lalu menjadi laci serba ada dari codebase, tempat setiap fungsi yang tidak punya rumah jelas berakhir.

Ini aturan saya: folder shared/ membutuhkan justifikasi. Sesuatu masuk ke shared/ hanya jika:

  1. Digunakan oleh 3+ fitur, dan
  2. Benar-benar generik (bukan logika bisnis yang menyamar jadi generik)

Fungsi format tanggal yang memformat tanggal jatuh tempo invoice? Itu masuk ke features/invoicing/utils/. Fungsi format tanggal yang menerima Date dan mengembalikan string locale? Itu bisa masuk ke shared/.

Subfolder shared/lib/ secara khusus untuk integrasi pihak ketiga dan infrastruktur: database client, library auth, payment provider. Ini adalah hal-hal yang seluruh aplikasi bergantung padanya tapi tidak ada satu fitur pun yang memilikinya.

typescript
// shared/lib/db.ts — ini memang masuk 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 — ini TIDAK masuk 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}`;
}

Jika Anda menuju ke shared/utils/ dan tidak bisa memberi nama file yang lebih spesifik dari helpers.ts, berhenti. Kode itu mungkin lebih cocok di dalam sebuah fitur.

Server vs. Client Components: Tarik Garis dengan Sengaja#

Berikut pola yang terus saya lihat di codebase yang punya masalah performa:

typescript
// Ini terlihat masuk akal tapi adalah kesalahan
"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>
  );
}

Halaman ini ditandai "use client". Sekarang semua yang diimpor, InvoiceTable, InvoiceFilters, hook, setiap dependensi dari setiap dependensi, masuk ke client bundle. Tabel 200 baris Anda yang bisa di-render di server tanpa JavaScript sama sekali sekarang dikirim ke browser.

Solusinya adalah mendorong "use client" turun ke komponen leaf yang benar-benar membutuhkannya:

typescript
// app/(dashboard)/invoices/page.tsx — Server Component (tanpa 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 — punya filter interaktif */}
      <InvoiceTable data={invoices} /> {/* Server Component — hanya merender baris */}
    </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>
  );
}

Aturan yang saya ikuti: setiap komponen adalah Server Component sampai ia membutuhkan useState, useEffect, event handler, atau browser API. Baru setelah itu, tambahkan "use client", dan usahakan komponen itu sekecil mungkin.

Pola children adalah sahabat terbaik Anda di sini. Client Component bisa menerima Server Components sebagai 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 bisa berupa Server Components */}
    </div>
  );
}

Beginilah cara Anda menjaga 90% component tree Anda di server sambil tetap punya interaktivitas di tempat yang diperlukan.

Barrel Files: Pembunuh Build yang Senyap#

Dulu saya menaruh index.ts di setiap folder. Import yang bersih, kan?

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

Lalu saya bisa mengimpor seperti ini:

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

Terlihat rapi. Ini masalahnya: ketika bundler melihat import itu, ia harus mengevaluasi seluruh barrel file untuk mencari tahu apa itu InvoiceTable. Itu berarti ia juga memuat InvoiceRow, InvoiceForm, dan InvoicePdf, meskipun Anda hanya butuh table.

Di satu proyek, menghapus barrel file internal menurunkan jumlah modul dari 11.000 ke 3.500 selama development. Dev server berubah dari page load 5-10 detik menjadi di bawah 2 detik. First load JavaScript turun dari lebih dari satu megabyte menjadi sekitar 200 KB.

Vercel membuat optimizePackageImports di next.config.ts untuk menangani ini bagi paket pihak ketiga dari node_modules. Tapi itu tidak memperbaiki barrel file Anda sendiri. Untuk kode Anda sendiri, jawabannya lebih sederhana: jangan gunakan barrel file.

typescript
// Lakukan ini
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// Bukan ini
import { InvoiceTable } from "@/features/invoicing/components";

Ya, path import-nya lebih panjang. Build Anda lebih cepat, bundle Anda lebih kecil, dan dev server Anda tidak tersedak. Itu trade-off yang selalu saya ambil.

Satu pengecualian: public API sebuah fitur. Jika features/invoicing/ mengekspos satu index.ts di level root fitur dengan hanya 2-3 hal yang boleh diimpor fitur lain, itu tidak masalah. Itu berfungsi sebagai batas, bukan pintasan kenyamanan. Tapi barrel file di dalam subfolder seperti components/index.ts atau hooks/index.ts? Hapus saja.

Halaman Tipis, Fitur Tebal#

Aturan arsitektur paling penting yang saya ikuti adalah ini: file page adalah wiring, bukan logika.

File page di app/ seharusnya melakukan tiga hal:

  1. Mengambil data (jika itu Server Component)
  2. Mengimpor komponen fitur
  3. Menyusunnya bersama

Itu saja. Tanpa logika bisnis. Tanpa conditional rendering yang kompleks. Tanpa JSX tree 200 baris. Jika file page tumbuh melewati 40 baris, Anda menaruh kode fitur di tempat yang salah.

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

Dua belas baris. Halaman mengambil data yang dibutuhkan, menangani kasus not-found, dan mendelegasikan semua yang lain ke fitur. Jika UI detail invoice berubah, Anda mengedit features/invoicing/, bukan app/. Jika route berubah, Anda memindahkan file page, dan kode fitur tidak peduli.

Beginilah cara Anda mendapatkan codebase di mana 15 developer bisa bekerja secara bersamaan tanpa saling menginjak.

Kenyataan yang Tidak Nyaman#

Tidak ada struktur folder yang mencegah kode buruk. Saya pernah melihat proyek yang terorganisir dengan indah penuh dengan abstraksi yang buruk, dan struktur flat yang berantakan tapi mudah dikerjakan karena kodenya sendiri jelas.

Struktur adalah alat, bukan tujuan. Pendekatan berbasis fitur yang saya jelaskan di sini berhasil karena mengoptimalkan hal yang paling penting dalam skala besar: bisakah developer yang belum pernah melihat codebase ini menemukan apa yang mereka butuhkan dan mengubahnya tanpa merusak hal lain?

Jika struktur Anda saat ini menjawab "ya" untuk pertanyaan itu, jangan refactor hanya karena sebuah blog post menyuruh Anda melakukannya, bahkan blog post ini.

Tapi jika developer Anda menghabiskan lebih banyak waktu menavigasi codebase daripada menulis kode, jika "ini harus ditaruh di mana?" adalah pertanyaan berulang di PR, jika perubahan di billing entah bagaimana merusak dashboard, maka struktur Anda melawan Anda. Dan pola-pola di atas adalah cara saya memperbaikinya, setiap kali.

Artikel Terkait