Przejdź do treści

Jak organizować kod w dużych projektach Next.js?

Twój folder z komponentami pęka w szwach? Zobacz, jak wdrożyć Feature-based architecture, utrzymać porządek w kodzie i ułatwić onboarding nowym deweloperom.

Maciej Sala

Founder StriveLab

6 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 17 lipca 2026)

Problem: domyślna struktura Next.js nie skaluje się w dużym projekcie

Typowe objawy:

  • Folder components/ z 80 plikami, gdzie Button.tsx leży obok InvoiceCalculator.tsx
  • lib/ jako worek na wszystko — od formatterów dat po logikę płatności
  • Zależności krzyżowe — komponent koszyka importuje coś z modułu użytkownika, który importuje coś z koszyka
  • Nikt w zespole nie wie, gdzie dodać nowy plik

Feature-based architecture w Next.js: główna zasada organizacyjna

(czasem opisywana jako domain-driven folder structure) grupuje kod według funkcjonalności biznesowej, nie według typu technicznego.

Zamiast:

Code
components/
├── Button.tsx
├── Card.tsx
├── InvoiceForm.tsx
├── InvoiceList.tsx
├── ProductCard.tsx
├── ProductGrid.tsx
├── UserAvatar.tsx
└── UserProfile.tsx

Masz:

Code
features/
├── invoices/
│   ├── components/
│   ├── hooks/
│   ├── actions/
│   └── types.ts
├── products/
│   ├── components/
│   ├── hooks/
│   ├── actions/
│   └── types.ts
└── users/
    ├── components/
    ├── hooks/
    ├── actions/
    └── types.ts

Każda feature to możliwie spójny moduł z własnymi komponentami, hookami, akcjami serwerowymi, typami i testami. Nie musi być całkowicie niezależna, ale jej zależności powinny być jawne, jednokierunkowe i prowadzić wyłącznie przez dozwolone publiczne wejścia.

Struktura projektu Next.js App Router w feature-based architecture

Code
src/
├── app/                          ← Trasy, layouty i kompozycja
│   ├── layout.tsx
│   ├── page.tsx
│   ├── (marketing)/              ← Route group — strony marketingowe
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── cennik/
│   │       └── page.tsx
│   ├── (dashboard)/              ← Route group — panel
│   │   ├── layout.tsx
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   ├── invoices/
│   │   │   ├── page.tsx
│   │   │   └── [id]/
│   │   │       └── page.tsx
│   │   └── products/
│   │       └── page.tsx
│   └── api/
│       └── webhooks/
│           └── stripe/
│               └── route.ts
│
├── features/                     ← Logika biznesowa
│   ├── auth/
│   │   ├── components/
│   │   │   ├── login-form.tsx
│   │   │   ├── register-form.tsx
│   │   │   └── auth-provider.tsx
│   │   ├── actions/
│   │   │   ├── login.ts
│   │   │   └── register.ts
│   │   ├── hooks/
│   │   │   └── use-session.ts
│   │   ├── lib/
│   │   │   ├── auth-config.ts
│   │   │   └── session.ts
│   │   ├── types.ts
│   │   └── index.ts              ← Public API feature
│   │
│   ├── invoices/
│   │   ├── components/
│   │   │   ├── invoice-form.tsx
│   │   │   ├── invoice-list.tsx
│   │   │   ├── invoice-card.tsx
│   │   │   └── invoice-status-badge.tsx
│   │   ├── actions/
│   │   │   ├── create-invoice.ts
│   │   │   ├── update-invoice.ts
│   │   │   └── delete-invoice.ts
│   │   ├── hooks/
│   │   │   └── use-invoice-filters.ts
│   │   ├── lib/
│   │   │   ├── invoice-calculations.ts
│   │   │   └── invoice-pdf.ts
│   │   ├── schemas/
│   │   │   └── create-invoice.ts
│   │   ├── types.ts
│   │   ├── server.ts             ← Publiczne wejście serwerowe
│   │   └── index.ts              ← Publiczne UI i typy
│   │
│   └── products/
│       ├── components/
│       ├── actions/
│       ├── hooks/
│       ├── lib/
│       ├── types.ts
│       └── index.ts
│
├── shared/                       ← Współdzielone zasoby
│   ├── components/               ← Generyczne komponenty UI
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── card.tsx
│   │   │   ├── input.tsx
│   │   │   └── modal.tsx
│   │   └── layout/
│   │       ├── header.tsx
│   │       ├── footer.tsx
│   │       └── sidebar.tsx
│   ├── hooks/
│   │   ├── use-debounce.ts
│   │   └── use-media-query.ts
│   ├── lib/
│   │   ├── db.ts
│   │   ├── auth.ts
│   │   ├── formatters.ts
│   │   ├── validators.ts
│   │   └── constants.ts
│   └── types/
│       └── global.ts
│
└── config/                       ← Konfiguracja
    ├── navigation.ts
    └── site.ts

Cztery zasady feature-based architecture

1. app/ w Next.js jest warstwą tras i kompozycji

Next.js pozwala bezpiecznie kolokować w app/ komponenty, funkcje i testy — plik nie staje się trasą, dopóki segment nie zawiera page.tsx albo route.ts. Cienkie page.tsx i layout.tsx są dobrą konwencją w dużym projekcie, ale nie wymogiem frameworka. W app/ naturalnie zostają pliki specjalne Next.js, metadane oraz kod ściśle związany z daną trasą; logikę domenową wielokrotnego użytku przenoś do features/.

Code
// app/(dashboard)/invoices/page.tsx
import { InvoiceList } from '@/features/invoices'
import { getInvoices } from '@/features/invoices/server'
 
export default async function InvoicesPage() {
  const invoices = await getInvoices()
 
  return (
    <div>
      <h1>Faktury</h1>
      <InvoiceList invoices={invoices} />
    </div>
  )
}

2. Każda feature ma jawne publiczne wejścia

Feature eksportuje przez tylko to, czego mogą używać inne moduły. W App Routerze jeden index.ts eksportujący jednocześnie komponenty klienckie, zapytania do bazy i Server Actions zaciera jednak granicę środowisk. Bezpieczniej rozdzielić publiczne wejścia, np. index.ts dla UI i typów, server.ts dla kodu serwerowego oraz dedykowane pliki akcji:

Code
// features/invoices/index.ts
// Public API — tylko te eksporty są dostępne na zewnątrz
 
// Komponenty
export { InvoiceList } from './components/invoice-list'
export { InvoiceForm } from './components/invoice-form'
export { InvoiceCard } from './components/invoice-card'
 
// Typy
export type { Invoice, InvoiceStatus, CreateInvoiceInput } from './types'
 
// NIE eksportuj:
// - invoice-calculations.ts (wewnętrzny helper)
// - invoice-status-badge.tsx (wewnętrzny komponent)
// - use-invoice-filters.ts (wewnętrzny hook)
Code
// features/invoices/server.ts
import 'server-only'
 
export { getInvoices, getInvoiceById } from './lib/queries'

Reguła importowania:

Code
// DOBRZE — import z public API
import { InvoiceList } from '@/features/invoices'
import { getInvoices } from '@/features/invoices/server'
import { createInvoice } from '@/features/invoices/actions/create-invoice'
 
// ŹLE — sięganie do wewnętrznych plików
import { InvoiceStatusBadge } from '@/features/invoices/components/invoice-status-badge'

'use client' wyznacza początek grafu modułów klienckich, więc wszystkie jego importy mogą trafić do bundla przeglądarki. Moduły z bazą, sekretami lub prywatnymi API oznaczaj przez import 'server-only'; dla kodu wymagającego window możesz analogicznie użyć client-only. Publiczne barrel files są wygodne, ale po zmianach sprawdzaj ich wpływ na granice serwer–klient i rozmiar paczki.

3. Reguła zależności: jawny kierunek i brak cykli

Bezpośrednie importy między features ograniczaj do ich publicznych wejść. Jeśli scenariusz łączy kilka domen, składaj go w stronie albo osobnej warstwie aplikacyjnej zamiast tworzyć cykl invoices → users → invoices:

Code
// ŹLE — invoice sięga do prywatnego wnętrza auth
import { getCurrentUser } from '@/features/auth/lib/session'
 
// DOBRZE — page przekazuje dane jako prop
// app/(dashboard)/invoices/page.tsx
import { getCurrentUser } from '@/features/auth'
import { InvoiceList } from '@/features/invoices'
import { getInvoices } from '@/features/invoices/server'
 
export default async function InvoicesPage() {
  const user = await getCurrentUser()
  const invoices = await getInvoices({ userId: user.id })
 
  return <InvoiceList invoices={invoices} userName={user.name} />
}

Sesja, autoryzacja techniczna, klient bazy i telemetryka są zależnościami przekrojowymi. Jeśli korzysta z nich większość features, wydziel ich stabilne kontrakty do shared/ albo core/. Ekrany logowania i reguły biznesowe użytkownika nadal mogą pozostać osobnymi features.

4. shared/ to fundament architektury, nie śmietnik

shared/ zawiera wyłącznie rzeczy, które:

  • Nie należą do żadnej domeny biznesowej (Button, Modal, formatDate)
  • Są używane przez 2+ features
  • Nie mają logiki specyficznej dla domeny

Jeśli komponent jest używany tylko w jednej feature — zostaje w tej feature, nawet jeśli „wygląda generycznie".

Server Actions w feature-based architecture

Code
// features/invoices/actions/create-invoice.ts
'use server'
 
import { requireUser } from '@/shared/lib/auth'
import { revalidatePath } from 'next/cache'
import { db } from '@/shared/lib/db'
import { createInvoiceSchema } from '../schemas/create-invoice'
 
export async function createInvoice(formData: FormData) {
  const user = await requireUser()
 
  const parsed = createInvoiceSchema.safeParse({
    clientName: formData.get('clientName'),
    amount: Number(formData.get('amount')),
    dueDate: formData.get('dueDate'),
  })
 
  if (!parsed.success) {
    return { error: 'Nieprawidłowe dane', details: parsed.error.flatten() }
  }
 
  await db.invoice.create({
    data: {
      ...parsed.data,
      userId: user.id,
      status: 'DRAFT',
    },
  })
 
  revalidatePath('/invoices')
  return { success: true }
}

Server Action jest publicznie osiągalnym endpointem aplikacji: walidacja danych nie zastępuje uwierzytelnienia i autoryzacji. Przy aktualizacji lub usuwaniu rekordu sprawdź po stronie serwera, czy użytkownik może wykonać operację na konkretnym zasobie. Nie ufaj identyfikatorowi właściciela przesłanemu w FormData. Klient bazy i moduł sesji oznacz dodatkowo przez import 'server-only', aby błędny import do Client Component zakończył się podczas buildu.

Gdzie umieszczać testy?

Testy jednostkowe i integracyjne trzymaj blisko kodu feature, np. obok modułu jako invoice-calculations.test.ts albo w lokalnym __tests__/. Ułatwia to przeniesienie lub usunięcie całej funkcjonalności. Testy przekrojowe obejmujące kilka features — zwłaszcza scenariusze przeglądarkowe — mogą pozostać w głównym tests/e2e/. Ważniejsza od wyboru jednego katalogu jest konsekwencja i rozdzielenie szybkich testów domeny od testów pełnej aplikacji.

Aliasy ścieżek w TypeScript dla dużego projektu Next.js

Aby importy były czytelne, skonfiguruj aliasy:

Code
// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@/features/*": ["./src/features/*"],
      "@/shared/*": ["./src/shared/*"],
      "@/config/*": ["./src/config/*"]
    }
  }
}

W aplikacji budowanej przez Next.js alias @/* zwykle wystarcza; pozostałe wpisy są redundantne, bo pasują już do pierwszego wzorca. paths pomaga TypeScriptowi i bundlerowi rozwiązywać importy, ale TypeScript nie przepisuje tych ścieżek w emitowanym JavaScripcie. Nie używaj więc aliasów jako zamiennika prawdziwych pakietów w monorepo.

Jak egzekwować granice modułów w zespole?

Sama dokumentacja nie zatrzyma importu do prywatnego pliku. Dodaj do ESLinta regułę no-restricted-imports albo narzędzie analizujące graf zależności i uruchamiaj je w CI. Reguły powinny wykrywać co najmniej importy do prywatnych katalogów, cykle oraz użycie modułu serwerowego w kodzie klienta. Test architektury jest szczególnie wartościowy po przenosinach plików, gdy automatyczny import IDE łatwo omija publiczne API.

Nie ukrywaj każdego cyklu przez przeniesienie kodu do shared/. Najpierw ustal, czy brakującym elementem jest kontrakt, zdarzenie domenowe albo warstwa orkiestracji. W przeciwnym razie shared/ szybko stanie się dawnym lib/ pod nową nazwą.

Kiedy wydzielić monorepo?

Folder features/ porządkuje jeden deployowalny produkt. Monorepo ma sens dopiero wtedy, gdy kilka aplikacji lub usług rzeczywiście współdzieli wersjonowane moduły, np. design system, klient API albo konfigurację lintowania. Takie moduły twórz jako pakiety workspace z jawnym package.json i polem exports, zamiast mapować sąsiednie katalogi przez tsconfig.paths. Jeśli lokalny pakiet wymaga transpilacji przez Next.js, dodaj go do transpilePackages.

Kiedy wprowadzić feature-based architecture w Next.js?

Nie od pierwszego dnia. Zaczynaj z prostą strukturą components/ + lib/. Gdy poczujesz, że:

  • Szukasz plików dłużej niż 10 sekund
  • Nie wiesz, gdzie dodać nowy komponent
  • Zmiany w jednym module „łamią" coś w innym
  • Nowy członek zespołu nie może się odnaleźć w kodzie

...wtedy refaktoryzuj do features. Migracja jest inkrementalna — przenoś domenę po domenie, nie wszystko naraz.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

Czy feature-based architecture działa z Pages Router?

Tak. Ta architektura dotyczy organizacji logiki, a nie konkretnego API routingu. W Pages Router katalog pages/ może być cienką warstwą importującą moduły z features/, choć nie ma tych samych konwencji i możliwości kolokacji co app/. Sama zasada grupowania kodu według domen biznesowych jest niezależna od tego, którego routera używasz.

Co, jeśli komponent jest używany w dwóch features?

Przenieś go do shared/components/, ale najpierw upewnij się, że jest naprawdę generyczny. Jeśli zawiera logikę specyficzną dla jednej domeny, lepsza bywa kontrolowana duplikacja niż tworzenie sztucznej, dziurawej abstrakcji, którą trzeba potem naginać do dwóch różnych przypadków. Współdzielenie ma sens dla rzeczy bez kontekstu domenowego — przycisku, modala, formatera daty.

Jak feature-based architecture ma się do Domain-Driven Design?

To nie jest uproszczony DDD, lecz sposób fizycznej organizacji kodu, który może wspierać model domenowy. Feature bywa mniejsza niż bounded context, a jeden kontekst może zawierać wiele features. DDD dotyczy przede wszystkim granic modelu, języka domenowego i zależności, nie nazw katalogów; agregaty ani event sourcing nie są obowiązkowe, aby stosować DDD.

Kiedy wprowadzić feature-based architecture w projekcie?

Nie od pierwszego dnia. W małym projekcie prosta struktura components/ plus lib/ jest w zupełności wystarczająca i wprowadzanie features byłoby przedwczesną komplikacją. Sygnałem do refaktoru jest moment, gdy szukasz plików dłużej niż kilkanaście sekund, nie wiesz, gdzie dodać nowy kod, a zmiany w jednym module psują inny. Wtedy migruj — przyrostowo, domena po domenie.

Dlaczego features nie powinny importować z innych features?

Nie chodzi o bezwzględny zakaz, lecz o uniknięcie cykli i niejawnego sięgania do wnętrza innych modułów. Proste zależności realizuj przez publiczne API, a scenariusze łączące kilka domen składaj w warstwie aplikacyjnej. Elementy przekrojowe, takie jak sesja czy klient bazy, umieść w niższej warstwie shared/ albo core/, zamiast ogłaszać jedną feature wyjątkiem od wszystkich reguł.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Jak analizować i zmniejszyć paczkę JS w Next.js?

Każdy kilobajt JavaScript, który wysyłasz do przeglądarki, musi być: pobrany transfer , sparsowany CPU , wykonany CPU i utrzymany w pamięci RAM . Na wolnej sieci i budżetowym telefonie setki kilobajtów JavaScriptu mogą opóźnić interakcję o kilka sekund.

Maciej Sala

Maciej Sala

Founder StriveLab

Zarządzanie starszymi architekturami: jak łączyć React, Svelte i Vue w jednym projekcie Astro

Są łatwiejsze, ale i trudniejsze migracje frontendu. Najtrudniejsza to taka, w której nie możemy przepisać wszystkiego od zera , ponieważ aplikacja działa na produkcji, generuje przychód i nie wolno jej zatrzymać. Jednorazowe przepisanie całego systemu w takiej sytuacji jest bardzo ryzykowne. W artykule piszę, jak architektura wysp w Astro pozwala Reactowi, Svelte i Vue współistnieć w jednym projekcie i jak wykorzystać to do stopniowej, bezpieczniejszej migracji.

Maciej Sala

Maciej Sala

Founder StriveLab

Astro vs Next.js w 2026: Porównanie frameworków

Astro czy Next.js? Wybór frameworka musi być dokładnie przemyślany, zanim pojawi się pierwszy commit. Jeśli stoisz przed takim właśnie wyborem, w tym artykule staram się wykazać, w jakich obszarach najlepiej sprawdza się Astro , a w jakich będzie dominował Next.js .

Maciej Sala

Maciej Sala

Founder StriveLab