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.
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
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/.
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// Komponentyexport { InvoiceList } from './components/invoice-list'export { InvoiceForm } from './components/invoice-form'export { InvoiceCard } from './components/invoice-card'// Typyexport 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.tsimport 'server-only'export { getInvoices, getInvoiceById } from './lib/queries'
Reguła importowania:
Code
// DOBRZE — import z public APIimport { 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ówimport { 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 authimport { getCurrentUser } from '@/features/auth/lib/session'// DOBRZE — page przekazuje dane jako prop// app/(dashboard)/invoices/page.tsximport { 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
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.
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.
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
Founder StriveLab
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
Founder StriveLab
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 .