İçeriğe geç
·10 dk okuma

Next.js Projelerini Büyük Ölçekte Yapılandırmak: Zor Yoldan Öğrendiklerim

Kendi ağırlığı altında çökmeyen Next.js kod tabanlarını organize etme üzerine zor kazanılmış dersler. Özellik tabanlı mimari, route grupları, sunucu/istemci sınırları, barrel dosyası tuzakları ve çalabileceğiniz gerçek bir klasör yapısı.

Paylaş:X / TwitterLinkedIn

Her Next.js projesi temiz başlar. Birkaç sayfa, bir components klasörü, belki bir lib/utils.ts. Kendinizi üretken hissedersiniz. Düzenli hissedersiniz.

Sonra altı ay geçer. Üç geliştirici katılır. Components klasöründe 140 dosya vardır. Birisi utils2.ts oluşturmuştur çünkü utils.ts 800 satırdı. Hangi layout'un hangi route'u sardığını kimse bilmez. Build'iniz dört dakika sürer ve nedenini bilmezsiniz.

Bu döngüyü birçok kez yaşadım. Aşağıda anlatacaklarım, bu derslerin çoğunu önce yanlış şeyi göndererek öğrendikten sonra şu anda gerçekten yaptığım şeyler.

Varsayılan Yapı Çabuk Çöker#

Next.js belgeleri bir proje yapısı önerir. İyidir. Kişisel bir blog veya küçük bir araç için çalışır. Ama aynı kod tabanına dokunan ikiden fazla geliştiriciye sahip olduğunuz an, "tür tabanlı" yaklaşım çatlamaya başlar:

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     ← sonun başlangıcı
  app/
    page.tsx
    dashboard/
      page.tsx

Sorun bunun "yanlış" olması değil. Sorun, özelliklerle doğrusal ölçeklenmesi ama kavrayışla ölçeklenmemesi. Yeni bir geliştirici faturalar üzerinde çalışması gerektiğinde, components/, hooks/, utils/, types/ ve app/ klasörlerini aynı anda açmak zorunda kalır. Fatura özelliği beş dizine dağılmıştır. "Faturalandırma böyle görünüyor" diyen tek bir yer yoktur.

Dört kişilik bir ekibin tam bir sprint harcayarak tam da bu yapıya geçiş yaptığını izledim, bunun "temiz" yol olduğuna ikna olmuşlardı. Üç ay içinde yine kafaları karışıktı, sadece daha fazla klasörle.

Özellik Tabanlı Birlikte Yerleşim Her Zaman Kazanır#

Gerçek bir ekiple temastan sağ çıkan kalıp, özellik tabanlı birlikte yerleşimdir. Fikir basittir: bir özellikle ilgili her şey bir arada yaşar.

"Tüm bileşenler bir klasörde ve tüm hook'lar başka bir klasörde" değil. Tam tersi. Faturalandırma için tüm bileşenler, hook'lar, tipler ve yardımcı fonksiyonlar features/invoicing/ içinde yaşar. Kimlik doğrulama için her şey features/auth/ içinde yaşar.

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

İmportlara dikkat edin. Hepsi göreceli, hepsi özellik içinde. Bu hook, global bir hooks/ klasörüne ya da paylaşılan bir utils/format.ts'ye uzanmıyor. Faturalandırma özelliğinin kendi biçimlendirme mantığını kullanıyor çünkü fatura numarası biçimlendirmesi bir faturalandırma meselesidir, global bir mesele değil.

Sezgisel olmayan kısım: bu, bir miktar tekrarınız olacağı anlamına gelir. Faturalandırma özelliğinizin bir format.ts'si olabilir ve faturalama özelliğinizin de bir format.ts'si olabilir. Bu sorun değil. Bu aslında 14 özelliğin bağımlı olduğu ve kimsenin güvenle değiştiremediği paylaşılan bir formatters.ts'den daha iyidir.

Buna uzun süre direndim. DRY kutsal bir ilkeydi. Ama paylaşılan yardımcı dosyaların bir kod tabanındaki en tehlikeli dosyalar haline geldiğini gördüm — bir özellik için yapılan "küçük bir düzeltmenin" üç başkasını bozduğu dosyalar. Kontrollü tekrarla birlikte yerleşim, zorla paylaşımdan daha sürdürülebilirdir.

Gerçekten Kullandığım Klasör Yapısı#

İşte tam ağaç. Bu, 4-8 kişilik ekiplerle iki üretim uygulamasında savaş testinden geçmiştir:

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

Dikkat edilmesi gereken birkaç şey:

  • app/ yalnızca yönlendirme mantığı içerir. Sayfa dosyaları incedir. features/'dan import ederler ve birleştirirler. Bir sayfa dosyası en fazla 20-40 satır olmalıdır.
  • features/ gerçek kodun yaşadığı yerdir. Her özellik kendi başına yeterlidir. features/invoicing/ klasörünü silebilirsiniz ve başka hiçbir şey bozulmaz (ondan import eden sayfalar hariç, ki bu tam olarak istediğiniz bağımlılıktır).
  • shared/ bir çöp kutusu değildir. Kesin alt kategorilere sahiptir. Buna aşağıda değineceğim.

Route Grupları: Sadece Organizasyondan Fazlası#

Route grupları, (parantez içi) klasörler, App Router'ın en az kullanılan özelliklerinden biridir. URL'yi hiç etkilemezler. (dashboard)/invoices/page.tsx, /dashboard/invoices değil /invoices adresinde render edilir.

Ama asıl güç layout izolasyonundadır. Her route grubu kendi layout.tsx'ine sahip olur:

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

Kimlik doğrulama kontrolü layout seviyesinde bir kez gerçekleşir, her sayfada değil. Herkese açık sayfalar tamamen farklı bir kabuk alır. Kimlik doğrulama sayfaları hiç layout almaz — sadece ortalanmış bir kart. Bu temiz, açık ve bozulması zordur.

Kimlik doğrulama kontrollerini middleware'e, bireysel sayfalara, bileşenlere — hepsine aynı anda koyan ekipler gördüm. Tek bir yer seçin. Çoğu uygulama için route grubu layout'u o yerdir.

shared/ Klasörü utils/ Değildir#

Bakım kabusu oluşturmanın en hızlı yolu bir lib/utils.ts dosyasıdır. Küçük başlar. Sonra kod tabanının çekmece gözüne dönüşür — belirgin bir yeri olmayan her fonksiyonun son bulduğu yer.

İşte benim kuralım: shared/ klasörü gerekçe gerektirir. Bir şey shared/'a yalnızca şu durumlarda girer:

  1. 3+ özellik tarafından kullanılıyorsa, ve
  2. Gerçekten genelse (genel kılığına girmiş iş mantığı değilse)

Fatura vade tarihlerini biçimlendiren bir tarih biçimlendirme fonksiyonu? O features/invoicing/utils/ içine gider. Bir Date alıp yerel ayar dizesi döndüren bir tarih biçimlendirme fonksiyonu? O shared/'a gidebilir.

shared/lib/ alt klasörü özellikle üçüncü taraf entegrasyonları ve altyapı içindir: veritabanı istemcileri, kimlik doğrulama kütüphaneleri, ödeme sağlayıcıları. Bunlar tüm uygulamanın bağımlı olduğu ama hiçbir özelliğin sahiplenmediği şeylerdir.

typescript
// shared/lib/db.ts — burası shared'a ait
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 — burası shared'a ait DEĞİL
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}`;
}

Eğer shared/utils/ klasörüne uzanıyorsanız ve dosyaya helpers.ts'den daha spesifik bir isim veremiyorsanız, durun. O kod muhtemelen bir özelliğin içine aittir.

Sunucu vs. İstemci Bileşenleri: Sınırı Bilinçli Çizin#

İşte performans sorunları olan kod tabanlarında sürekli gördüğüm bir kalıp:

typescript
// Bu makul görünüyor ama bir hata
"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>
  );
}

Sayfa "use client" olarak işaretlenmiş. Artık import ettiği her şey — InvoiceTable, InvoiceFilters, hook, her bağımlılığın her bağımlılığı — istemci paketinde. Sunucuda sıfır JavaScript ile render edilebilecek 200 satırlık tablonuz artık tarayıcıya gönderiliyor.

Çözüm, "use client" direktifini gerçekten ihtiyaç duyan yaprak bileşenlere itmektir:

typescript
// app/(dashboard)/invoices/page.tsx — Sunucu Bileşeni (direktif yok)
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 />          {/* İstemci Bileşeni — etkileşimli filtreler içerir */}
      <InvoiceTable data={invoices} /> {/* Sunucu Bileşeni — sadece satırları render eder */}
    </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>
  );
}

Uyguladığım kural: her bileşen, useState, useEffect, olay işleyicileri veya tarayıcı API'lerine ihtiyaç duyana kadar bir Sunucu Bileşenidir. O zaman ve ancak o zaman "use client" ekleyin ve o bileşeni olabildiğince küçük tutmaya çalışın.

Children kalıbı burada en iyi dostunuzdur. Bir İstemci Bileşeni, children olarak Sunucu Bileşenlerini alabilir:

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 Sunucu Bileşenleri olabilir */}
    </div>
  );
}

Bileşen ağacınızın %90'ını sunucuda tutarken ihtiyaç duyulan yerlerde etkileşim sağlamanın yolu budur.

Barrel Dosyaları: Sessiz Build Katili#

Eskiden her klasöre bir index.ts koyardım. Temiz importlar, değil mi?

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

Sonra şöyle import edebilirdim:

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

Güzel görünüyor. İşte sorun: paketleyici bu importu gördüğünde, InvoiceTable'ın ne olduğunu anlamak için barrel dosyasının tamamını değerlendirmek zorunda. Bu da sadece tabloya ihtiyacınız olsa bile InvoiceRow, InvoiceForm ve InvoicePdf'i de yüklediği anlamına gelir.

Bir projede, dahili barrel dosyalarını kaldırmak geliştirme sırasında modül sayısını 11.000'den 3.500'e düşürdü. Geliştirme sunucusu 5-10 saniyelik sayfa yüklemelerinden 2 saniyenin altına indi. İlk yükleme JavaScript'i bir megabaytın üzerinden yaklaşık 200 KB'ye düştü.

Vercel, node_modules'dan gelen üçüncü taraf paketler için next.config.ts'de optimizePackageImports özelliğini geliştirdi. Ama kendi barrel dosyalarınızı düzeltmez. Kendi kodunuz için cevap daha basittir: kullanmayın.

typescript
// Bunu yapın
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// Bunu değil
import { InvoiceTable } from "@/features/invoicing/components";

Evet, import yolu daha uzun. Build'iniz daha hızlı, paketleriniz daha küçük ve geliştirme sunucunuz tıkanmıyor. Bu takası her seferinde kabul ederim.

Tek istisna: bir özelliğin genel API'si. Eğer features/invoicing/ özellik kök seviyesinde diğer özelliklerin import etmesine izin verilen sadece 2-3 şeyi içeren tek bir index.ts sunuyorsa, sorun yok. Bu bir kolaylık kısayolu değil, bir sınır görevi görür. Ama components/index.ts veya hooks/index.ts gibi alt klasörlerdeki barrel dosyaları? Onları kaldırın.

İnce Sayfalar, Kalın Özellikler#

Uyguladığım en önemli mimari kural şudur: sayfa dosyaları bağlantıdır, mantık değil.

app/ içindeki bir sayfa dosyası üç şey yapmalıdır:

  1. Veri çekmek (Sunucu Bileşeniyse)
  2. Özellik bileşenlerini import etmek
  3. Onları birleştirmek

Bu kadar. İş mantığı yok. Karmaşık koşullu render yok. 200 satırlık JSX ağaçları yok. Bir sayfa dosyası 40 satırı geçiyorsa, özellik kodunu yanlış yere koyuyorsunuz.

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

On iki satır. Sayfa ihtiyacı olan veriyi çeker, bulunamadı durumunu yönetir ve geri kalan her şeyi özelliğe devreder. Fatura detay arayüzü değişirse, app/ değil features/invoicing/ düzenlersiniz. Route değişirse, sayfa dosyasını taşırsınız ve özellik kodu umursamaz.

15 geliştiricinin birbirinin ayağına basmadan aynı anda çalışabildiği bir kod tabanını bu şekilde elde edersiniz.

Rahatsız Edici Gerçek#

Kötü kodu önleyen hiçbir klasör yapısı yoktur. Korkunç soyutlamalarla dolu güzelce organize edilmiş projeler gördüm ve kodun kendisi açık olduğu için çalışması kolay olan düzensiz düz yapılar gördüm.

Yapı bir araçtır, bir amaç değil. Burada anlattığım özellik tabanlı yaklaşım işe yarar çünkü ölçekte en çok önemli olan şeyi optimize eder: bu kod tabanını hiç görmemiş bir geliştirici, ihtiyacı olanı bulup başka bir şeyi bozmadan değiştirebilir mi?

Eğer mevcut yapınız bu soruya "evet" diye cevap veriyorsa, bir blog yazısı size söyledi diye yeniden yapılandırmayın — bu yazı bile olsa.

Ama geliştiricileriniz kod yazmaktan çok kod tabanında gezinmeye zaman harcıyorsa, "bu nereye gider?" PR'larda tekrarlayan bir soruysa, faturalamadaki bir değişiklik bir şekilde kontrol panelini bozuyorsa — o zaman yapınız size karşı savaşıyor. Ve yukarıdaki kalıplar bunu her seferinde nasıl düzelttiğimi gösteriyor.

İlgili Yazılar